mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-26 02:38:12 +08:00
Redux refactor (#1571)
* refactor categorical controls state * lint * fix race condition in tests * fix typo * add missing update on subset * remove obsolete code * update jest and puppeteer major version; update all minors * update when label changes * remove lint from tests; increase timeouts in e2e tests * initial refactoring to new async annomatrix * refine error handling * fix bad merge * add continuous legend * lint * fix memoization in color table creators * partial implementation of user defined annotations * add new annotations action creator file * first pass at user annotations * additional user annotation bug fixes * user annotation auto-save * unit test cleanup * lint * refactor into multiple files * cleanup * add column GC * fix several bugs in user annotations * remove debug code * no anonymous functions * undo redo cleanup * file cleanup * scatterplot * performance * cleanup * remove old code * render in parallel with load * fix race condition * simply graph rendering * render throttle DRY * fix category label order * fix typo in e2e test setup * re-fix the e2e test setup * be more tolerant of races * anno matrix unit tests * temp disable reembedding * pilot port continuous histo to react-async * name change * lint * fix repaint bug * typo fix * update snap to match new ids * world/universe name cleanup * move annoMatrix to src dir * use private underscore naming convention * fix corner case in all selected * name cleanup * add layout control * init edge case * lint * port scatterplot * fix label indexing bug and improve tests * port category to react-async * fix user annotation labelling while subset * select all of prev layout on layout switch * fix race with crossfilter update * prettier lint * fix misleading comment * fix url composition in loader * first pass at crossfilter tests * lint * lint * fix typo * improved error handling for network errors * fix memoization bug * add memo * refactor for performnce * add missing single-value handling in select exact parser * small bugs discovered by tests * lint * additional crossfilter unit tests * remove extraneous comment * add support for automatic category determination * lint * fix render bug in category * take advantage of schema categories guarantee * lint * do not clear history when resetting * enhanced annomatrix gc * lint * finish renaming to follow conventions; fix clone race bug * lint * add priority based loading to improve initial data load UX * crossfilter cache perf * perf tuning * remove timers * documentation * PR review changes * PR review changes * more PR review edits * improve clarity of comment * more PR review fixes * port centroidLabels to use react-async * remove dead code * pr review updates * oops, remove logging
This commit is contained in:
@@ -2,14 +2,14 @@
|
||||
|
||||
exports[`annotations stacked bar graph renders 1`] = `
|
||||
Array [
|
||||
"<div data-flip-config=\\"{"translate":true,"scale":true,"opacity":true}\\" data-portal-key=\\"portal\\" class=\\"categorical__value___2RKaC\\" data-testclass=\\"categorical-row\\" style=\\"padding: 4px 0px 4px 7px; display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 2px; border-radius: 2px;\\"><div style=\\"margin: 0px; padding: 0px; user-select: none; width: 220px; display: flex; justify-content: space-between;\\"><div style=\\"display: flex; align-items: baseline;\\"><label for=\\"value-toggle-checkbox-TEST-LABEL\\" class=\\"bp3-control bp3-checkbox\\" style=\\"margin: 0px;\\"><input id=\\"value-toggle-checkbox-TEST-LABEL\\" data-testclass=\\"categorical-value-select\\" data-testid=\\"categorical-value-select-TEST-CATEGORY-TEST-LABEL\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"categorical-value-TEST-CATEGORY-TEST-LABEL\\" data-testclass=\\"categorical-value\\" aria-label=\\"TEST-LABEL\\" class=\\"\\" tabindex=\\"0\\" style=\\"width: 63px; color: black; font-style: normal; display: inline-block; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px;\\"><span style=\\"width: 63px; color: black; font-style: normal; display: flex; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">TEST-</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">LABEL</span><span style=\\"position: absolute; right: 0px; color: black;\\">LABEL</span></span></span></span></span></span></div><span style=\\"flex-shrink: 0;\\"></span></div><div><span><span data-testclass=\\"categorical-value-count\\" data-testid=\\"categorical-value-count-TEST-CATEGORY-TEST-LABEL\\" style=\\"color: black;\\">0</span><svg display=\\"none\\" style=\\"margin-left: 5px; width: 11px; height: 11px; background-color: inherit;\\"></svg><span><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><button type=\\"button\\" data-testclass=\\"seeActions\\" data-testid=\\"TEST-CATEGORY:TEST-LABEL:see-actions\\" class=\\"bp3-button bp3-minimal bp3-small\\" tabindex=\\"0\\" style=\\"margin-left: 2px; position: relative; top: -1px; min-height: 16px;\\"><span icon=\\"more\\" class=\\"bp3-icon bp3-icon-more\\"><svg data-icon=\\"more\\" width=\\"10\\" height=\\"10\\" viewBox=\\"0 0 16 16\\"><desc>more</desc><path d=\\"M2 6.03a2 2 0 100 4 2 2 0 100-4zM14 6.03a2 2 0 100 4 2 2 0 100-4zM8 6.03a2 2 0 100 4 2 2 0 100-4z\\" fill-rule=\\"evenodd\\"></path></svg></span></button></span></span></span></span></div></div>",
|
||||
"<div data-flip-config=\\"{"translate":true,"scale":true,"opacity":true}\\" data-flip-id=\\"1\\" data-portal-key=\\"portal\\" class=\\"categorical__value___2RKaC\\" data-testclass=\\"categorical-row\\" style=\\"padding: 4px 0px 4px 7px; display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 2px; border-radius: 2px;\\"><div style=\\"margin: 0px; padding: 0px; user-select: none; width: 220px; display: flex; justify-content: space-between;\\"><div style=\\"display: flex; align-items: baseline;\\"><label for=\\"value-toggle-checkbox-unassigned\\" class=\\"bp3-control bp3-checkbox\\" style=\\"margin: 0px;\\"><input id=\\"value-toggle-checkbox-unassigned\\" data-testclass=\\"categorical-value-select\\" data-testid=\\"categorical-value-select-TEST-CATEGORY-unassigned\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"categorical-value-TEST-CATEGORY-unassigned\\" data-testclass=\\"categorical-value\\" aria-label=\\"unassigned\\" class=\\"\\" tabindex=\\"0\\" style=\\"width: 63px; color: rgb(171, 171, 171); font-style: italic; display: inline-block; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px;\\"><span style=\\"width: 63px; color: rgb(171, 171, 171); font-style: italic; display: flex; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">unass</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">igned</span><span style=\\"position: absolute; right: 0px; color: rgb(171, 171, 171);\\">igned</span></span></span></span></span></span></div><span style=\\"flex-shrink: 0;\\"><canvas class=\\"bp3-popover-targer\\" width=\\"100\\" height=\\"11\\" style=\\"margin-right: 5px; width: 100px; height: 11px;\\"></canvas></span></div><div><span><span data-testclass=\\"categorical-value-count\\" data-testid=\\"categorical-value-count-TEST-CATEGORY-unassigned\\" style=\\"color: rgb(171, 171, 171); font-style: italic;\\">2132</span><svg display=\\"none\\" style=\\"margin-left: 5px; width: 11px; height: 11px; background-color: inherit;\\"></svg><span><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><button type=\\"button\\" data-testclass=\\"seeActions\\" data-testid=\\"TEST-CATEGORY:unassigned:see-actions\\" class=\\"bp3-button bp3-minimal bp3-small\\" tabindex=\\"0\\" style=\\"margin-left: 2px; position: relative; top: -1px; min-height: 16px;\\"><span icon=\\"more\\" class=\\"bp3-icon bp3-icon-more\\"><svg data-icon=\\"more\\" width=\\"10\\" height=\\"10\\" viewBox=\\"0 0 16 16\\"><desc>more</desc><path d=\\"M2 6.03a2 2 0 100 4 2 2 0 100-4zM14 6.03a2 2 0 100 4 2 2 0 100-4zM8 6.03a2 2 0 100 4 2 2 0 100-4z\\" fill-rule=\\"evenodd\\"></path></svg></span></button></span></span></span></span></div></div>",
|
||||
"<div data-flip-config=\\"{"translate":true,"scale":true,"opacity":true}\\" data-portal-key=\\"portal\\" class=\\"categorical__value___2RKaC\\" data-testclass=\\"categorical-row\\" style=\\"padding: 4px 0px 4px 7px; display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 2px; border-radius: 2px;\\"><div style=\\"margin: 0px; padding: 0px; user-select: none; width: 220px; display: flex; justify-content: space-between;\\"><div style=\\"display: flex; align-items: baseline;\\"><label for=\\"value-toggle-checkbox-TEST-CATEGORY-TEST-LABEL\\" class=\\"bp3-control bp3-checkbox\\" style=\\"margin: 0px;\\"><input id=\\"value-toggle-checkbox-TEST-CATEGORY-TEST-LABEL\\" data-testclass=\\"categorical-value-select\\" data-testid=\\"categorical-value-select-TEST-CATEGORY-TEST-LABEL\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"categorical-value-TEST-CATEGORY-TEST-LABEL\\" data-testclass=\\"categorical-value\\" aria-label=\\"TEST-LABEL\\" class=\\"\\" tabindex=\\"0\\" style=\\"width: 63px; color: black; font-style: normal; display: inline-block; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px;\\"><span style=\\"width: 63px; color: black; font-style: normal; display: flex; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">TEST-</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">LABEL</span><span style=\\"position: absolute; right: 0px; color: black;\\">LABEL</span></span></span></span></span></span></div><span style=\\"flex-shrink: 0;\\"></span></div><div><span><span data-testclass=\\"categorical-value-count\\" data-testid=\\"categorical-value-count-TEST-CATEGORY-TEST-LABEL\\" style=\\"color: black;\\">0</span><svg display=\\"none\\" style=\\"margin-left: 5px; width: 11px; height: 11px; background-color: inherit;\\"></svg><span><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><button type=\\"button\\" data-testclass=\\"seeActions\\" data-testid=\\"TEST-CATEGORY:TEST-LABEL:see-actions\\" class=\\"bp3-button bp3-minimal bp3-small\\" tabindex=\\"0\\" style=\\"margin-left: 2px; position: relative; top: -1px; min-height: 16px;\\"><span icon=\\"more\\" class=\\"bp3-icon bp3-icon-more\\"><svg data-icon=\\"more\\" width=\\"10\\" height=\\"10\\" viewBox=\\"0 0 16 16\\"><desc>more</desc><path d=\\"M2 6.03a2 2 0 100 4 2 2 0 100-4zM14 6.03a2 2 0 100 4 2 2 0 100-4zM8 6.03a2 2 0 100 4 2 2 0 100-4z\\" fill-rule=\\"evenodd\\"></path></svg></span></button></span></span></span></span></div></div>",
|
||||
"<div data-flip-config=\\"{"translate":true,"scale":true,"opacity":true}\\" data-flip-id=\\"1\\" data-portal-key=\\"portal\\" class=\\"categorical__value___2RKaC\\" data-testclass=\\"categorical-row\\" style=\\"padding: 4px 0px 4px 7px; display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 2px; border-radius: 2px;\\"><div style=\\"margin: 0px; padding: 0px; user-select: none; width: 220px; display: flex; justify-content: space-between;\\"><div style=\\"display: flex; align-items: baseline;\\"><label for=\\"value-toggle-checkbox-TEST-CATEGORY-unassigned\\" class=\\"bp3-control bp3-checkbox\\" style=\\"margin: 0px;\\"><input id=\\"value-toggle-checkbox-TEST-CATEGORY-unassigned\\" data-testclass=\\"categorical-value-select\\" data-testid=\\"categorical-value-select-TEST-CATEGORY-unassigned\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"categorical-value-TEST-CATEGORY-unassigned\\" data-testclass=\\"categorical-value\\" aria-label=\\"unassigned\\" class=\\"\\" tabindex=\\"0\\" style=\\"width: 63px; color: rgb(171, 171, 171); font-style: italic; display: inline-block; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px;\\"><span style=\\"width: 63px; color: rgb(171, 171, 171); font-style: italic; display: flex; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">unass</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">igned</span><span style=\\"position: absolute; right: 0px; color: rgb(171, 171, 171);\\">igned</span></span></span></span></span></span></div><span style=\\"flex-shrink: 0;\\"><canvas class=\\"bp3-popover-targer\\" width=\\"100\\" height=\\"11\\" style=\\"margin-right: 5px; width: 100px; height: 11px;\\"></canvas></span></div><div><span><span data-testclass=\\"categorical-value-count\\" data-testid=\\"categorical-value-count-TEST-CATEGORY-unassigned\\" style=\\"color: rgb(171, 171, 171); font-style: italic;\\">2132</span><svg display=\\"none\\" style=\\"margin-left: 5px; width: 11px; height: 11px; background-color: inherit;\\"></svg><span><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><button type=\\"button\\" data-testclass=\\"seeActions\\" data-testid=\\"TEST-CATEGORY:unassigned:see-actions\\" class=\\"bp3-button bp3-minimal bp3-small\\" tabindex=\\"0\\" style=\\"margin-left: 2px; position: relative; top: -1px; min-height: 16px;\\"><span icon=\\"more\\" class=\\"bp3-icon bp3-icon-more\\"><svg data-icon=\\"more\\" width=\\"10\\" height=\\"10\\" viewBox=\\"0 0 16 16\\"><desc>more</desc><path d=\\"M2 6.03a2 2 0 100 4 2 2 0 100-4zM14 6.03a2 2 0 100 4 2 2 0 100-4zM8 6.03a2 2 0 100 4 2 2 0 100-4z\\" fill-rule=\\"evenodd\\"></path></svg></span></button></span></span></span></span></div></div>",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`annotations stacked bar graph renders 2`] = `
|
||||
Array [
|
||||
"<div data-flip-config=\\"{"translate":true,"scale":true,"opacity":true}\\" data-portal-key=\\"portal\\" class=\\"categorical__value___2RKaC\\" data-testclass=\\"categorical-row\\" style=\\"padding: 4px 0px 4px 7px; display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 2px; border-radius: 2px;\\"><div style=\\"margin: 0px; padding: 0px; user-select: none; width: 220px; display: flex; justify-content: space-between;\\"><div style=\\"display: flex; align-items: baseline;\\"><label for=\\"value-toggle-checkbox-TEST-LABEL\\" class=\\"bp3-control bp3-checkbox\\" style=\\"margin: 0px;\\"><input id=\\"value-toggle-checkbox-TEST-LABEL\\" data-testclass=\\"categorical-value-select\\" data-testid=\\"categorical-value-select-TEST-CATEGORY-TEST-LABEL\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"categorical-value-TEST-CATEGORY-TEST-LABEL\\" data-testclass=\\"categorical-value\\" aria-label=\\"TEST-LABEL\\" class=\\"\\" tabindex=\\"0\\" style=\\"width: 63px; color: black; font-style: normal; display: inline-block; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px;\\"><span style=\\"width: 63px; color: black; font-style: normal; display: flex; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">TEST-</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">LABEL</span><span style=\\"position: absolute; right: 0px; color: black;\\">LABEL</span></span></span></span></span></span></div><span style=\\"flex-shrink: 0;\\"></span></div><div><span><span data-testclass=\\"categorical-value-count\\" data-testid=\\"categorical-value-count-TEST-CATEGORY-TEST-LABEL\\" style=\\"color: black;\\">0</span><svg display=\\"none\\" style=\\"margin-left: 5px; width: 11px; height: 11px; background-color: inherit;\\"></svg><span><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><button type=\\"button\\" data-testclass=\\"seeActions\\" data-testid=\\"TEST-CATEGORY:TEST-LABEL:see-actions\\" class=\\"bp3-button bp3-minimal bp3-small\\" tabindex=\\"0\\" style=\\"margin-left: 2px; position: relative; top: -1px; min-height: 16px;\\"><span icon=\\"more\\" class=\\"bp3-icon bp3-icon-more\\"><svg data-icon=\\"more\\" width=\\"10\\" height=\\"10\\" viewBox=\\"0 0 16 16\\"><desc>more</desc><path d=\\"M2 6.03a2 2 0 100 4 2 2 0 100-4zM14 6.03a2 2 0 100 4 2 2 0 100-4zM8 6.03a2 2 0 100 4 2 2 0 100-4z\\" fill-rule=\\"evenodd\\"></path></svg></span></button></span></span></span></span></div></div>",
|
||||
"<div data-flip-config=\\"{"translate":true,"scale":true,"opacity":true}\\" data-flip-id=\\"1\\" data-portal-key=\\"portal\\" class=\\"categorical__value___2RKaC\\" data-testclass=\\"categorical-row\\" style=\\"padding: 4px 0px 4px 7px; display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 2px; border-radius: 2px;\\"><div style=\\"margin: 0px; padding: 0px; user-select: none; width: 220px; display: flex; justify-content: space-between;\\"><div style=\\"display: flex; align-items: baseline;\\"><label for=\\"value-toggle-checkbox-unassigned\\" class=\\"bp3-control bp3-checkbox\\" style=\\"margin: 0px;\\"><input id=\\"value-toggle-checkbox-unassigned\\" data-testclass=\\"categorical-value-select\\" data-testid=\\"categorical-value-select-TEST-CATEGORY-unassigned\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"categorical-value-TEST-CATEGORY-unassigned\\" data-testclass=\\"categorical-value\\" aria-label=\\"unassigned\\" class=\\"\\" tabindex=\\"0\\" style=\\"width: 63px; color: rgb(171, 171, 171); font-style: italic; display: inline-block; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px;\\"><span style=\\"width: 63px; color: rgb(171, 171, 171); font-style: italic; display: flex; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">unass</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">igned</span><span style=\\"position: absolute; right: 0px; color: rgb(171, 171, 171);\\">igned</span></span></span></span></span></span></div><span style=\\"flex-shrink: 0;\\"><canvas class=\\"bp3-popover-targer\\" width=\\"100\\" height=\\"11\\" style=\\"margin-right: 5px; width: 100px; height: 11px;\\"></canvas></span></div><div><span><span data-testclass=\\"categorical-value-count\\" data-testid=\\"categorical-value-count-TEST-CATEGORY-unassigned\\" style=\\"color: rgb(171, 171, 171); font-style: italic;\\">2638</span><svg display=\\"none\\" style=\\"margin-left: 5px; width: 11px; height: 11px; background-color: inherit;\\"></svg><span><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><button type=\\"button\\" data-testclass=\\"seeActions\\" data-testid=\\"TEST-CATEGORY:unassigned:see-actions\\" class=\\"bp3-button bp3-minimal bp3-small\\" tabindex=\\"0\\" style=\\"margin-left: 2px; position: relative; top: -1px; min-height: 16px;\\"><span icon=\\"more\\" class=\\"bp3-icon bp3-icon-more\\"><svg data-icon=\\"more\\" width=\\"10\\" height=\\"10\\" viewBox=\\"0 0 16 16\\"><desc>more</desc><path d=\\"M2 6.03a2 2 0 100 4 2 2 0 100-4zM14 6.03a2 2 0 100 4 2 2 0 100-4zM8 6.03a2 2 0 100 4 2 2 0 100-4z\\" fill-rule=\\"evenodd\\"></path></svg></span></button></span></span></span></span></div></div>",
|
||||
"<div data-flip-config=\\"{"translate":true,"scale":true,"opacity":true}\\" data-portal-key=\\"portal\\" class=\\"categorical__value___2RKaC\\" data-testclass=\\"categorical-row\\" style=\\"padding: 4px 0px 4px 7px; display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 2px; border-radius: 2px;\\"><div style=\\"margin: 0px; padding: 0px; user-select: none; width: 220px; display: flex; justify-content: space-between;\\"><div style=\\"display: flex; align-items: baseline;\\"><label for=\\"value-toggle-checkbox-TEST-CATEGORY-TEST-LABEL\\" class=\\"bp3-control bp3-checkbox\\" style=\\"margin: 0px;\\"><input id=\\"value-toggle-checkbox-TEST-CATEGORY-TEST-LABEL\\" data-testclass=\\"categorical-value-select\\" data-testid=\\"categorical-value-select-TEST-CATEGORY-TEST-LABEL\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"categorical-value-TEST-CATEGORY-TEST-LABEL\\" data-testclass=\\"categorical-value\\" aria-label=\\"TEST-LABEL\\" class=\\"\\" tabindex=\\"0\\" style=\\"width: 63px; color: black; font-style: normal; display: inline-block; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px;\\"><span style=\\"width: 63px; color: black; font-style: normal; display: flex; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">TEST-</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">LABEL</span><span style=\\"position: absolute; right: 0px; color: black;\\">LABEL</span></span></span></span></span></span></div><span style=\\"flex-shrink: 0;\\"></span></div><div><span><span data-testclass=\\"categorical-value-count\\" data-testid=\\"categorical-value-count-TEST-CATEGORY-TEST-LABEL\\" style=\\"color: black;\\">0</span><svg display=\\"none\\" style=\\"margin-left: 5px; width: 11px; height: 11px; background-color: inherit;\\"></svg><span><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><button type=\\"button\\" data-testclass=\\"seeActions\\" data-testid=\\"TEST-CATEGORY:TEST-LABEL:see-actions\\" class=\\"bp3-button bp3-minimal bp3-small\\" tabindex=\\"0\\" style=\\"margin-left: 2px; position: relative; top: -1px; min-height: 16px;\\"><span icon=\\"more\\" class=\\"bp3-icon bp3-icon-more\\"><svg data-icon=\\"more\\" width=\\"10\\" height=\\"10\\" viewBox=\\"0 0 16 16\\"><desc>more</desc><path d=\\"M2 6.03a2 2 0 100 4 2 2 0 100-4zM14 6.03a2 2 0 100 4 2 2 0 100-4zM8 6.03a2 2 0 100 4 2 2 0 100-4z\\" fill-rule=\\"evenodd\\"></path></svg></span></button></span></span></span></span></div></div>",
|
||||
"<div data-flip-config=\\"{"translate":true,"scale":true,"opacity":true}\\" data-flip-id=\\"1\\" data-portal-key=\\"portal\\" class=\\"categorical__value___2RKaC\\" data-testclass=\\"categorical-row\\" style=\\"padding: 4px 0px 4px 7px; display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 2px; border-radius: 2px;\\"><div style=\\"margin: 0px; padding: 0px; user-select: none; width: 220px; display: flex; justify-content: space-between;\\"><div style=\\"display: flex; align-items: baseline;\\"><label for=\\"value-toggle-checkbox-TEST-CATEGORY-unassigned\\" class=\\"bp3-control bp3-checkbox\\" style=\\"margin: 0px;\\"><input id=\\"value-toggle-checkbox-TEST-CATEGORY-unassigned\\" data-testclass=\\"categorical-value-select\\" data-testid=\\"categorical-value-select-TEST-CATEGORY-unassigned\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><span data-testid=\\"categorical-value-TEST-CATEGORY-unassigned\\" data-testclass=\\"categorical-value\\" aria-label=\\"unassigned\\" class=\\"\\" tabindex=\\"0\\" style=\\"width: 63px; color: rgb(171, 171, 171); font-style: italic; display: inline-block; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px;\\"><span style=\\"width: 63px; color: rgb(171, 171, 171); font-style: italic; display: flex; overflow: hidden; line-height: 1.1em; height: 1.1em; vertical-align: middle; margin-right: 16px; justify-content: flex-start;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">unass</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">igned</span><span style=\\"position: absolute; right: 0px; color: rgb(171, 171, 171);\\">igned</span></span></span></span></span></span></div><span style=\\"flex-shrink: 0;\\"><canvas class=\\"bp3-popover-targer\\" width=\\"100\\" height=\\"11\\" style=\\"margin-right: 5px; width: 100px; height: 11px;\\"></canvas></span></div><div><span><span data-testclass=\\"categorical-value-count\\" data-testid=\\"categorical-value-count-TEST-CATEGORY-unassigned\\" style=\\"color: rgb(171, 171, 171); font-style: italic;\\">2638</span><svg display=\\"none\\" style=\\"margin-left: 5px; width: 11px; height: 11px; background-color: inherit;\\"></svg><span><span class=\\"bp3-popover-wrapper\\"><span class=\\"bp3-popover-target\\"><button type=\\"button\\" data-testclass=\\"seeActions\\" data-testid=\\"TEST-CATEGORY:unassigned:see-actions\\" class=\\"bp3-button bp3-minimal bp3-small\\" tabindex=\\"0\\" style=\\"margin-left: 2px; position: relative; top: -1px; min-height: 16px;\\"><span icon=\\"more\\" class=\\"bp3-icon bp3-icon-more\\"><svg data-icon=\\"more\\" width=\\"10\\" height=\\"10\\" viewBox=\\"0 0 16 16\\"><desc>more</desc><path d=\\"M2 6.03a2 2 0 100 4 2 2 0 100-4zM14 6.03a2 2 0 100 4 2 2 0 100-4zM8 6.03a2 2 0 100 4 2 2 0 100-4z\\" fill-rule=\\"evenodd\\"></path></svg></span></button></span></span></span></span></div></div>",
|
||||
]
|
||||
`;
|
||||
|
||||
@@ -304,7 +304,7 @@ describe.each([
|
||||
node.getAttribute("aria-label")
|
||||
);
|
||||
|
||||
expect(result).toBe(categoryName);
|
||||
return expect(result).toBe(categoryName);
|
||||
}
|
||||
|
||||
async function assertLabelExists(categoryName, labelName) {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// these TWO statements MUST be first in the file, before any other imports
|
||||
import { enableFetchMocks } from "jest-fetch-mock";
|
||||
import * as serverMocks from "./serverMocks";
|
||||
// OK, continue on!
|
||||
|
||||
import {
|
||||
AnnoMatrixLoader,
|
||||
clip,
|
||||
isubset,
|
||||
isubsetMask,
|
||||
} from "../../../src/annoMatrix";
|
||||
import { Dataframe } from "../../../src/util/dataframe";
|
||||
|
||||
enableFetchMocks();
|
||||
|
||||
describe("AnnoMatrix", () => {
|
||||
let annoMatrix;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetch.resetMocks(); // reset all fetch mocking state
|
||||
annoMatrix = new AnnoMatrixLoader(
|
||||
serverMocks.baseDataURL,
|
||||
serverMocks.schema.schema
|
||||
);
|
||||
});
|
||||
|
||||
describe("basics", () => {
|
||||
test("annomatrix static checks", () => {
|
||||
expect(annoMatrix).toBeDefined();
|
||||
expect(annoMatrix.schema).toMatchObject(serverMocks.schema.schema);
|
||||
expect(annoMatrix.nObs).toEqual(serverMocks.schema.schema.dataframe.nObs);
|
||||
expect(annoMatrix.nVar).toEqual(serverMocks.schema.schema.dataframe.nVar);
|
||||
expect(annoMatrix.isView).toBeFalsy();
|
||||
expect(annoMatrix.viewOf).toBeUndefined();
|
||||
expect(annoMatrix.rowIndex).toBeDefined();
|
||||
});
|
||||
|
||||
test("simple single column fetch", async () => {
|
||||
fetch.once(serverMocks.annotationsObs(["name_0"]));
|
||||
|
||||
const df = await annoMatrix.fetch("obs", "name_0");
|
||||
expect(df).toBeInstanceOf(Dataframe);
|
||||
expect(df.colIndex.labels()).toEqual(["name_0"]);
|
||||
expect(df.dims).toEqual([annoMatrix.nObs, 1]);
|
||||
});
|
||||
|
||||
test("simple multi column fetch", async () => {
|
||||
fetch
|
||||
.once(serverMocks.annotationsObs(["name_0"]))
|
||||
.once(serverMocks.annotationsObs(["n_genes"]));
|
||||
|
||||
await expect(
|
||||
annoMatrix.fetch("obs", ["name_0", "n_genes"])
|
||||
).resolves.toBeInstanceOf(Dataframe);
|
||||
});
|
||||
|
||||
describe("fetch from field", () => {
|
||||
const getLastTwo = async (field) => {
|
||||
const columnNames = annoMatrix.getMatrixColumns(field).slice(-2);
|
||||
fetch.mockResponses(...columnNames.map(() => serverMocks.responder));
|
||||
await expect(
|
||||
annoMatrix.fetch(field, columnNames)
|
||||
).resolves.toBeInstanceOf(Dataframe);
|
||||
};
|
||||
|
||||
test("obs", async () => getLastTwo("obs"));
|
||||
test("var", async () => getLastTwo("var"));
|
||||
test("emb", async () => getLastTwo("emb"));
|
||||
});
|
||||
|
||||
test("fetch - test all query forms", async () => {
|
||||
// single string is a column name
|
||||
fetch.once(serverMocks.annotationsObs(["n_genes"]));
|
||||
await expect(annoMatrix.fetch("obs", "n_genes")).resolves.toBeInstanceOf(
|
||||
Dataframe
|
||||
);
|
||||
|
||||
// array of column names, expecting n_genes to be cached.
|
||||
fetch.once(serverMocks.annotationsObs(["percent_mito"]));
|
||||
await expect(
|
||||
annoMatrix.fetch("obs", ["n_genes", "percent_mito"])
|
||||
).resolves.toBeInstanceOf(Dataframe);
|
||||
|
||||
// more complex value filter query, enumerated
|
||||
fetch.once(serverMocks.responder);
|
||||
await expect(
|
||||
annoMatrix.fetch("X", {
|
||||
field: "var",
|
||||
column: annoMatrix.schema.annotations.var.index,
|
||||
value: "TYMP",
|
||||
})
|
||||
).resolves.toBeInstanceOf(Dataframe);
|
||||
|
||||
// more complex value filter query, range
|
||||
const varIndex = annoMatrix.schema.annotations.var.index;
|
||||
fetch
|
||||
.once(
|
||||
serverMocks.withExpected("/data/var", [[`var:${varIndex}`, "SUMO3"]])
|
||||
)
|
||||
.once(
|
||||
serverMocks.withExpected("/data/var", [[`var:${varIndex}`, "TYMP"]])
|
||||
);
|
||||
await expect(
|
||||
annoMatrix.fetch("X", [
|
||||
{
|
||||
field: "var",
|
||||
column: varIndex,
|
||||
value: "SUMO3",
|
||||
},
|
||||
{
|
||||
field: "var",
|
||||
column: varIndex,
|
||||
value: "TYMP",
|
||||
},
|
||||
])
|
||||
).resolves.toBeInstanceOf(Dataframe);
|
||||
// XXX inspect the wherecache?
|
||||
});
|
||||
|
||||
test("push and pop views", async () => {
|
||||
const am1 = clip(annoMatrix, 0.1, 0.9);
|
||||
expect(am1.viewOf).toBe(annoMatrix);
|
||||
expect(am1.nObs).toEqual(annoMatrix.nObs);
|
||||
expect(am1.nVar).toEqual(annoMatrix.nVar);
|
||||
expect(am1.rowIndex).toBe(annoMatrix.rowIndex);
|
||||
|
||||
const am2 = clip(annoMatrix, 0.1, 0.9);
|
||||
expect(am2.viewOf).toBe(annoMatrix);
|
||||
expect(am2).not.toBe(am1);
|
||||
expect(am2.rowIndex).toBe(annoMatrix.rowIndex);
|
||||
});
|
||||
|
||||
test("schema accessors", () => {
|
||||
expect(annoMatrix.getMatrixFields()).toEqual(
|
||||
expect.arrayContaining(["X", "obs", "emb", "var"])
|
||||
);
|
||||
expect(annoMatrix.getMatrixColumns("obs")).toEqual(
|
||||
expect.arrayContaining(["name_0", "n_genes", "louvain"])
|
||||
);
|
||||
expect(annoMatrix.getColumnSchema("emb", "umap")).toEqual({
|
||||
name: "umap",
|
||||
dims: ["umap_0", "umap_1"],
|
||||
type: "float32",
|
||||
});
|
||||
expect(annoMatrix.getColumnDimensions("emb", "umap")).toEqual([
|
||||
"umap_0",
|
||||
"umap_1",
|
||||
]);
|
||||
});
|
||||
|
||||
/*
|
||||
test the mask & label access to subset via isubset and isubsetMask
|
||||
*/
|
||||
test("isubset", async () => {
|
||||
const rowList = [0, 10];
|
||||
const rowMask = new Uint8Array(annoMatrix.nObs);
|
||||
for (let i = 0; i < rowList.length; i += 1) {
|
||||
rowMask[rowList[i]] = 1;
|
||||
}
|
||||
|
||||
const am1 = isubset(annoMatrix, rowList);
|
||||
const am2 = isubsetMask(annoMatrix, rowMask);
|
||||
expect(am1).not.toBe(am2);
|
||||
expect(am1.nObs).toEqual(2);
|
||||
expect(am1.nObs).toEqual(am2.nObs);
|
||||
expect(am1.nVar).toEqual(am2.nVar);
|
||||
|
||||
fetch
|
||||
.once(serverMocks.annotationsObs(["n_genes"]))
|
||||
.once(serverMocks.annotationsObs(["n_genes"]));
|
||||
const ng1 = await am1.fetch("obs", "n_genes");
|
||||
const ng2 = await am2.fetch("obs", "n_genes");
|
||||
expect(ng1).toHaveLength(ng2.length);
|
||||
expect(ng1.colIndex.labels()).toEqual(ng2.colIndex.labels());
|
||||
expect(ng1.col("n_genes").asArray()).toEqual(
|
||||
ng2.col("n_genes").asArray()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("add/drop column", () => {
|
||||
async function addDrop(base) {
|
||||
expect(base.getMatrixColumns("obs")).not.toContain("foo");
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
await expect(base.fetch("obs", "foo")).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
|
||||
/* add */
|
||||
const am1 = base.addObsColumn(
|
||||
{ name: "foo", type: "float32", writable: true },
|
||||
Float32Array,
|
||||
0
|
||||
);
|
||||
expect(base.getMatrixColumns("obs")).not.toContain("foo");
|
||||
expect(am1.getMatrixColumns("obs")).toContain("foo");
|
||||
const foo = await am1.fetch("obs", "foo");
|
||||
expect(foo).toBeDefined();
|
||||
expect(foo).toBeInstanceOf(Dataframe);
|
||||
expect(foo).toHaveLength(am1.nObs);
|
||||
expect(foo.col("foo").asArray()).toEqual(
|
||||
new Float32Array(am1.nObs).fill(0)
|
||||
);
|
||||
|
||||
/* drop */
|
||||
const am2 = am1.dropObsColumn("foo");
|
||||
expect(base.getMatrixColumns("obs")).not.toContain("foo");
|
||||
expect(am2.getMatrixColumns("obs")).not.toContain("foo");
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
await expect(am2.fetch("obs", "foo")).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
}
|
||||
|
||||
test("add/drop column, without view", async () => {
|
||||
await addDrop(annoMatrix);
|
||||
});
|
||||
|
||||
test("add/drop column, with view", async () => {
|
||||
const am1 = clip(annoMatrix, 0.1, 0.9);
|
||||
await addDrop(am1);
|
||||
|
||||
const am2 = isubset(am1, [0, 1, 2, 20, 30, 400]);
|
||||
await addDrop(am2);
|
||||
|
||||
const am3 = isubset(annoMatrix, [10, 0, 7, 3]);
|
||||
await addDrop(am3);
|
||||
|
||||
const am4 = clip(am3, 0, 1);
|
||||
await addDrop(am4);
|
||||
|
||||
fetch.mockResponse(serverMocks.responder);
|
||||
|
||||
await am1.fetch("obs", am1.getMatrixColumns("obs"));
|
||||
await am2.fetch("obs", am2.getMatrixColumns("obs"));
|
||||
await am3.fetch("obs", am3.getMatrixColumns("obs"));
|
||||
await am4.fetch("obs", am4.getMatrixColumns("obs"));
|
||||
|
||||
fetch.resetMocks();
|
||||
|
||||
await addDrop(am1);
|
||||
await addDrop(am2);
|
||||
await addDrop(am3);
|
||||
await addDrop(am4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setObsColumnValues", () => {
|
||||
async function addSetDrop(base) {
|
||||
/* add column */
|
||||
let am = base.addObsColumn(
|
||||
{
|
||||
name: "test",
|
||||
type: "categorical",
|
||||
categories: ["unassigned", "red", "green"],
|
||||
writable: true,
|
||||
},
|
||||
Array,
|
||||
"unassigned"
|
||||
);
|
||||
|
||||
const testVal = await am.fetch("obs", "test");
|
||||
expect(testVal.col("test").asArray()).toEqual(
|
||||
new Array(am.nObs).fill("unassigned")
|
||||
);
|
||||
|
||||
/* set values in column */
|
||||
const whichRows = [1, 2, 10];
|
||||
const am1 = await am.setObsColumnValues("test", whichRows, "yo");
|
||||
const testVal1 = await am1.fetch("obs", "test");
|
||||
const expt = new Array(am1.nObs).fill("unassigned");
|
||||
for (let i = 0; i < whichRows.length; i += 1) {
|
||||
const offset = am1.rowIndex.getOffset(whichRows[i]);
|
||||
expt[offset] = "yo";
|
||||
}
|
||||
expect(testVal1).not.toBe(testVal);
|
||||
expect(testVal1.col("test").asArray()).toEqual(expt);
|
||||
expect(am1.getColumnSchema("obs", "test").type).toBe("categorical");
|
||||
expect(am1.getColumnSchema("obs", "test").categories).toEqual(
|
||||
expect.arrayContaining(["unassigned", "red", "green", "yo"])
|
||||
);
|
||||
|
||||
/* drop column */
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
am = am1.dropObsColumn("test");
|
||||
await expect(am.fetch("obs", "test")).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
}
|
||||
|
||||
test("set, without a view", async () => {
|
||||
await addSetDrop(annoMatrix);
|
||||
});
|
||||
|
||||
test("set, with a view", async () => {
|
||||
const am1 = clip(annoMatrix, 0.1, 0.9);
|
||||
await addSetDrop(am1);
|
||||
|
||||
const am2 = isubset(am1, [0, 1, 2, 10, 20, 30, 400]);
|
||||
await addSetDrop(am2);
|
||||
|
||||
const am3 = isubset(annoMatrix, [10, 1, 0, 30, 2]);
|
||||
await addSetDrop(am3);
|
||||
|
||||
fetch.mockResponse(serverMocks.responder);
|
||||
|
||||
await am1.fetch("obs", am1.getMatrixColumns("obs"));
|
||||
await am2.fetch("obs", am2.getMatrixColumns("obs"));
|
||||
await am3.fetch("obs", am3.getMatrixColumns("obs"));
|
||||
|
||||
await addSetDrop(am1);
|
||||
await addSetDrop(am2);
|
||||
await addSetDrop(am3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,688 @@
|
||||
// these TWO statements MUST be first in the file, before any other imports
|
||||
import { enableFetchMocks } from "jest-fetch-mock";
|
||||
import * as serverMocks from "./serverMocks";
|
||||
// OK, continue on!
|
||||
|
||||
import obsLouvain from "./louvain.json";
|
||||
import obsNGenes from "./n_genes.json";
|
||||
import embUmap from "./umap.json";
|
||||
|
||||
import {
|
||||
AnnoMatrixLoader,
|
||||
AnnoMatrixObsCrossfilter,
|
||||
isubsetMask,
|
||||
} from "../../../src/annoMatrix";
|
||||
import { rangeFill } from "../../../src/util/range";
|
||||
|
||||
enableFetchMocks();
|
||||
|
||||
describe("AnnoMatrixCrossfilter", () => {
|
||||
let annoMatrix;
|
||||
let crossfilter;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetch.resetMocks(); // reset all fetch mocking state
|
||||
annoMatrix = new AnnoMatrixLoader(
|
||||
serverMocks.baseDataURL,
|
||||
serverMocks.schema.schema
|
||||
);
|
||||
crossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||
});
|
||||
|
||||
test("initial state of crossfilter", () => {
|
||||
const { nObs } = annoMatrix;
|
||||
|
||||
expect(crossfilter).toBeDefined();
|
||||
expect(crossfilter.size()).toEqual(nObs);
|
||||
expect(crossfilter.annoMatrix).toBe(annoMatrix);
|
||||
|
||||
/* by default, everything should be selected, even if no data in cache */
|
||||
expect(crossfilter.countSelected()).toEqual(nObs);
|
||||
expect(crossfilter.allSelectedLabels()).toEqual(
|
||||
rangeFill(new Int32Array(nObs))
|
||||
);
|
||||
expect(crossfilter.allSelectedMask()).toEqual(new Uint8Array(nObs).fill(1));
|
||||
expect(crossfilter.fillByIsSelected(new Uint8Array(nObs), 2, 1)).toEqual(
|
||||
new Uint8Array(nObs).fill(2)
|
||||
);
|
||||
});
|
||||
|
||||
describe("select", () => {
|
||||
/*
|
||||
test the selection state via crossfilter proxy
|
||||
*/
|
||||
|
||||
test("select loads index", async () => {
|
||||
/*
|
||||
Select should transparently load/create dimension index.
|
||||
|
||||
Internal dimension names are field/col:col:col..., eg,
|
||||
|
||||
obs:louvain
|
||||
emb:umap_0:umap_1
|
||||
|
||||
*/
|
||||
expect(crossfilter.obsCrossfilter.dimensionNames()).toEqual([]);
|
||||
expect(
|
||||
crossfilter.obsCrossfilter.hasDimension("obs/louvain")
|
||||
).toBeFalsy();
|
||||
|
||||
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
|
||||
let newCrossfilter = await crossfilter.select("obs", "louvain", {
|
||||
mode: "none",
|
||||
});
|
||||
|
||||
expect(newCrossfilter.countSelected()).toEqual(0);
|
||||
expect(
|
||||
newCrossfilter.obsCrossfilter.hasDimension("obs/louvain")
|
||||
).toBeTruthy();
|
||||
expect(fetch.mock.calls).toHaveLength(1);
|
||||
|
||||
newCrossfilter = await crossfilter.select("obs", "louvain", {
|
||||
mode: "all",
|
||||
});
|
||||
expect(newCrossfilter.countSelected()).toEqual(annoMatrix.nObs);
|
||||
});
|
||||
|
||||
test("simple column select", async () => {
|
||||
let xfltr;
|
||||
|
||||
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
|
||||
xfltr = await crossfilter.select("obs", "louvain", {
|
||||
mode: "exact",
|
||||
values: ["NK cells", "B cells"],
|
||||
});
|
||||
|
||||
expect(xfltr).toBeDefined();
|
||||
expect(xfltr.countSelected()).toEqual(496);
|
||||
expect(xfltr.allSelectedMask()).toEqual(
|
||||
Uint8Array.from(
|
||||
obsLouvain.map((val) =>
|
||||
val === "NK cells" || val === "B cells" ? 1 : 0
|
||||
)
|
||||
)
|
||||
);
|
||||
expect(xfltr.allSelectedLabels()).toEqual(
|
||||
Int32Array.from(
|
||||
obsLouvain.reduce((acc, val, idx) => {
|
||||
if (val === "NK cells" || val === "B cells") acc.push(idx);
|
||||
return acc;
|
||||
}, [])
|
||||
)
|
||||
);
|
||||
expect(
|
||||
xfltr.fillByIsSelected(new Uint8Array(annoMatrix.nObs), 3, 1)
|
||||
).toEqual(
|
||||
Uint8Array.from(
|
||||
obsLouvain.map((val) =>
|
||||
val === "NK cells" || val === "B cells" ? 3 : 1
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const df = await annoMatrix.fetch("obs", "louvain");
|
||||
const values = df.col("louvain").asArray();
|
||||
const selected = xfltr.allSelectedMask();
|
||||
values.every(
|
||||
(val, idx) => !["NK cells", "B cells"].includes(val) !== !selected[idx]
|
||||
);
|
||||
|
||||
fetch.once(
|
||||
serverMocks.dataframeResponse(["n_genes"], [new Int32Array(obsNGenes)])
|
||||
);
|
||||
xfltr = await xfltr.select("obs", "n_genes", {
|
||||
mode: "range",
|
||||
lo: 0,
|
||||
hi: 500,
|
||||
inclusive: false,
|
||||
});
|
||||
expect(xfltr.countSelected()).toEqual(33);
|
||||
expect(xfltr.allSelectedLabels()).toEqual(
|
||||
Int32Array.from(
|
||||
obsNGenes.reduce((acc, val, idx) => {
|
||||
const louvain = obsLouvain[idx];
|
||||
if (
|
||||
val >= 0 &&
|
||||
val < 500 &&
|
||||
(louvain === "NK cells" || louvain === "B cells")
|
||||
)
|
||||
acc.push(idx);
|
||||
return acc;
|
||||
}, [])
|
||||
)
|
||||
);
|
||||
|
||||
xfltr = await xfltr.selectAll();
|
||||
expect(xfltr.countSelected()).toEqual(annoMatrix.nObs);
|
||||
});
|
||||
|
||||
test("join column select", async () => {
|
||||
const varIndex = annoMatrix.schema.annotations.var.index;
|
||||
|
||||
const { nObs } = annoMatrix.schema.dataframe;
|
||||
fetch.once(
|
||||
serverMocks.dataframeResponse(
|
||||
["TEST"],
|
||||
[rangeFill(new Float32Array(nObs), 0, 0.1)]
|
||||
)
|
||||
);
|
||||
|
||||
const xfltr = await crossfilter.select(
|
||||
"X",
|
||||
{
|
||||
field: "var",
|
||||
column: varIndex,
|
||||
value: "TYMP",
|
||||
},
|
||||
{
|
||||
mode: "range",
|
||||
lo: 0,
|
||||
hi: 50,
|
||||
inclusive: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(xfltr).toBeDefined();
|
||||
expect(xfltr.countSelected()).toEqual(501);
|
||||
|
||||
const df = await annoMatrix.fetch("X", {
|
||||
field: "var",
|
||||
column: varIndex,
|
||||
value: "TYMP",
|
||||
});
|
||||
const values = df.icol(0).asArray();
|
||||
const selected = xfltr.allSelectedMask();
|
||||
values.every((val, idx) => !(val >= 0 && val <= 50) !== !selected[idx]);
|
||||
expect(selected.reduce((acc, val) => (val ? acc + 1 : acc), 0)).toEqual(
|
||||
xfltr.countSelected()
|
||||
);
|
||||
});
|
||||
|
||||
test("spatial column select", async () => {
|
||||
fetch.once(
|
||||
serverMocks.dataframeResponse(
|
||||
["umap_0", "umap_1"],
|
||||
[Float32Array.from(embUmap[0]), Float32Array.from(embUmap[1])]
|
||||
)
|
||||
);
|
||||
const xfltr = await crossfilter.select("emb", "umap", {
|
||||
mode: "within-rect",
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 0.5,
|
||||
maxY: 0.5,
|
||||
});
|
||||
expect(xfltr.countSelected()).toEqual(16);
|
||||
});
|
||||
|
||||
test("select on subset", async () => {
|
||||
const mask = new Uint8Array(annoMatrix.nObs).fill(0);
|
||||
for (let i = 0; i < mask.length; i += 2) {
|
||||
mask[i] = true;
|
||||
}
|
||||
const annoMatrixSubset = isubsetMask(annoMatrix, mask);
|
||||
expect(annoMatrixSubset.nObs).toEqual(Math.floor(annoMatrix.nObs / 2));
|
||||
|
||||
let xfltr = new AnnoMatrixObsCrossfilter(annoMatrixSubset);
|
||||
expect(xfltr.countSelected()).toEqual(annoMatrixSubset.nObs);
|
||||
|
||||
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
|
||||
xfltr = await xfltr.select("obs", "louvain", {
|
||||
mode: "exact",
|
||||
values: ["NK cells", "B cells"],
|
||||
});
|
||||
|
||||
expect(xfltr).toBeDefined();
|
||||
expect(xfltr.countSelected()).toEqual(240);
|
||||
|
||||
const df = await annoMatrixSubset.fetch("obs", "louvain");
|
||||
const values = df.col("louvain").asArray();
|
||||
const selected = xfltr.allSelectedMask();
|
||||
values.every(
|
||||
(val, idx) => !["NK cells", "B cells"].includes(val) !== !selected[idx]
|
||||
);
|
||||
});
|
||||
|
||||
test("select catches errors", async () => {
|
||||
await expect(crossfilter.select("NADA", "foo")).rejects.toThrow(
|
||||
"Unknown field name"
|
||||
);
|
||||
|
||||
await expect(crossfilter.select("var", "foo")).rejects.toThrow(
|
||||
"unable to obsSelect upon the var dimension"
|
||||
);
|
||||
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
await expect(crossfilter.select("obs", "foo")).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mutate matrix", () => {
|
||||
/*
|
||||
test the matrix mutators via crossfilter proxy
|
||||
*/
|
||||
async function helperAddTestCol(cf, colName, colSchema = null) {
|
||||
expect(
|
||||
cf.annoMatrix.getMatrixColumns("obs").includes(colName)
|
||||
).toBeFalsy();
|
||||
|
||||
if (colSchema === null) {
|
||||
colSchema = {
|
||||
name: colName,
|
||||
type: "categorical",
|
||||
categories: ["toasty"],
|
||||
};
|
||||
}
|
||||
colSchema.name = colName;
|
||||
const initValue = colSchema.categories[0];
|
||||
const xfltr = cf.addObsColumn(colSchema, Array, initValue);
|
||||
expect(
|
||||
xfltr.annoMatrix.schema.annotations.obs.columns.filter(
|
||||
(v) => v.name === colName
|
||||
)
|
||||
).toHaveLength(1);
|
||||
const df = await xfltr.annoMatrix.fetch("obs", colName);
|
||||
expect(df.hasCol(colName)).toBeTruthy();
|
||||
return xfltr;
|
||||
}
|
||||
|
||||
test("addObsColumn", async () => {
|
||||
expect(crossfilter.countSelected()).toBe(annoMatrix.nObs);
|
||||
expect(
|
||||
crossfilter.annoMatrix.getMatrixColumns("obs").includes("foo")
|
||||
).toBeFalsy();
|
||||
const xfltr = crossfilter.addObsColumn(
|
||||
{ name: "foo", type: "categorical", categories: ["A"] },
|
||||
Array,
|
||||
"A"
|
||||
);
|
||||
|
||||
// check schema updates correctly.
|
||||
expect(xfltr.countSelected()).toBe(annoMatrix.nObs);
|
||||
expect(
|
||||
xfltr.annoMatrix.getMatrixColumns("obs").includes("foo")
|
||||
).toBeTruthy();
|
||||
expect(xfltr.annoMatrix.schema.annotations.obsByName.foo).toMatchObject({
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
});
|
||||
expect(
|
||||
xfltr.annoMatrix.schema.annotations.obs.columns.filter(
|
||||
(v) => v.name === "foo"
|
||||
)
|
||||
).toHaveLength(1);
|
||||
|
||||
// check data update.
|
||||
const df = await xfltr.annoMatrix.fetch("obs", "foo");
|
||||
expect(
|
||||
df
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.every((v) => v === "A")
|
||||
).toBeTruthy();
|
||||
|
||||
// check that we catch dups
|
||||
expect(() =>
|
||||
xfltr.addObsColumn(
|
||||
{ name: "foo", type: "categorical" },
|
||||
Array,
|
||||
"toasty"
|
||||
)
|
||||
).toThrow("column already exists");
|
||||
expect(() =>
|
||||
xfltr.addObsColumn(
|
||||
{ name: "louvain", type: "categorical" },
|
||||
Array,
|
||||
"toasty"
|
||||
)
|
||||
).toThrow("column already exists");
|
||||
});
|
||||
|
||||
test("dropObsColumn", async () => {
|
||||
let xfltr;
|
||||
|
||||
/* check that we catch attempt to drop readonly dimension */
|
||||
expect(() => crossfilter.dropObsColumn("louvain")).toThrow(
|
||||
"Unknown or readonly obs column"
|
||||
);
|
||||
/* non-existent column */
|
||||
expect(() => crossfilter.dropObsColumn("does-not-exist")).toThrow(
|
||||
"Unknown or readonly obs column"
|
||||
);
|
||||
|
||||
// add a column, then drop it.
|
||||
xfltr = await helperAddTestCol(crossfilter, "foo");
|
||||
xfltr = xfltr.dropObsColumn("foo");
|
||||
expect(
|
||||
xfltr.annoMatrix.schema.annotations.obs.columns.filter(
|
||||
(v) => v.name === "foo"
|
||||
)
|
||||
).toHaveLength(0);
|
||||
expect(xfltr.annoMatrix.schema.annotations.obsByName.foo).toBeUndefined();
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
await expect(xfltr.annoMatrix.fetch("obs", "foo")).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
|
||||
// now same, but ensure we have built an index before doing the drop
|
||||
xfltr = await helperAddTestCol(crossfilter, "bar");
|
||||
xfltr = await xfltr.select("obs", "bar", {
|
||||
mode: "exact",
|
||||
values: "whatever",
|
||||
});
|
||||
xfltr = xfltr.dropObsColumn("bar");
|
||||
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
await expect(xfltr.select("obs", "bar", { mode: "all" })).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
});
|
||||
|
||||
test("renameObsColumn", async () => {
|
||||
let xfltr;
|
||||
|
||||
/* catch attempts to rename non-existent or readonly columns */
|
||||
expect(() =>
|
||||
crossfilter.renameObsColumn("does-not-exist", "foo")
|
||||
).toThrow("Unknown or readonly obs column");
|
||||
expect(() => crossfilter.renameObsColumn("louvain", "foo")).toThrow(
|
||||
"Unknown or readonly obs column"
|
||||
);
|
||||
|
||||
// add a column, then rename it.
|
||||
xfltr = await helperAddTestCol(crossfilter, "foo");
|
||||
xfltr = xfltr.renameObsColumn("foo", "bar");
|
||||
expect(xfltr.annoMatrix.getColumnSchema("obs", "foo")).toBeUndefined();
|
||||
expect(xfltr.annoMatrix.getColumnSchema("obs", "bar")).toMatchObject({
|
||||
name: "bar",
|
||||
type: "categorical",
|
||||
});
|
||||
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
await expect(xfltr.annoMatrix.fetch("obs", "foo")).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
const df = await xfltr.annoMatrix.fetch("obs", "bar");
|
||||
expect(df.hasCol("bar")).toBeTruthy();
|
||||
|
||||
// now same, but ensure we have built an index before doing the rename
|
||||
xfltr = await helperAddTestCol(crossfilter, "bar");
|
||||
xfltr = await xfltr.select("obs", "bar", {
|
||||
mode: "exact",
|
||||
values: "whatever",
|
||||
});
|
||||
xfltr = xfltr.renameObsColumn("bar", "xyz");
|
||||
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
await expect(xfltr.select("obs", "bar", { mode: "all" })).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
await expect(
|
||||
xfltr.select("obs", "xyz", { mode: "none" })
|
||||
).resolves.toBeInstanceOf(AnnoMatrixObsCrossfilter);
|
||||
});
|
||||
|
||||
test("addObsAnnoCategory", async () => {
|
||||
let xfltr;
|
||||
|
||||
// catch unknown or readonly columns
|
||||
expect(() => crossfilter.addObsAnnoCategory("louvain", "mumble")).toThrow(
|
||||
"Unknown or readonly obs column"
|
||||
);
|
||||
expect(() =>
|
||||
crossfilter.addObsAnnoCategory("undefined-name", "mumble")
|
||||
).toThrow("Unknown or readonly obs column");
|
||||
|
||||
// add a column and then add category to it
|
||||
xfltr = await helperAddTestCol(crossfilter, "foo", {
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: ["unassigned"],
|
||||
});
|
||||
xfltr = xfltr.addObsAnnoCategory("foo", "a-new-label");
|
||||
expect(xfltr.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: expect.arrayContaining(["a-new-label", "unassigned"]),
|
||||
});
|
||||
|
||||
// do it again, dup; should throw
|
||||
expect(() => xfltr.addObsAnnoCategory("foo", "a-new-label")).toThrow(
|
||||
"category already exists"
|
||||
);
|
||||
|
||||
// now same, but ensure we have built an index before doing the operation
|
||||
xfltr = await helperAddTestCol(crossfilter, "bar", {
|
||||
name: "bar",
|
||||
type: "categorical",
|
||||
categories: ["unassigned"],
|
||||
});
|
||||
xfltr = await xfltr.select("obs", "bar", {
|
||||
mode: "exact",
|
||||
values: "something",
|
||||
});
|
||||
xfltr = xfltr.addObsAnnoCategory("bar", "a-new-label");
|
||||
expect(xfltr.annoMatrix.getColumnSchema("obs", "bar")).toMatchObject({
|
||||
name: "bar",
|
||||
type: "categorical",
|
||||
categories: expect.arrayContaining(["a-new-label", "unassigned"]),
|
||||
});
|
||||
});
|
||||
|
||||
test("removeObsAnnoCategory", async () => {
|
||||
let xfltr;
|
||||
|
||||
// catch unknown or readonly categories
|
||||
await expect(() =>
|
||||
crossfilter.removeObsAnnoCategory("louvain", "mumble", "unassigned")
|
||||
).rejects.toThrow("Unknown or readonly obs column");
|
||||
await expect(() =>
|
||||
crossfilter.removeObsAnnoCategory("undefined-name", "mumble")
|
||||
).rejects.toThrow("Unknown or readonly obs column");
|
||||
|
||||
xfltr = await helperAddTestCol(crossfilter, "foo", {
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: ["unassigned", "red", "green", "blue"],
|
||||
});
|
||||
xfltr = await xfltr.select("obs", "foo", { mode: "all" });
|
||||
expect(
|
||||
(await xfltr.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.every((v) => v === "unassigned")
|
||||
).toBeTruthy();
|
||||
expect(xfltr.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: expect.arrayContaining([
|
||||
"unassigned",
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
]),
|
||||
});
|
||||
|
||||
// remove an unused category
|
||||
const xfltr1 = await xfltr.removeObsAnnoCategory("foo", "red", "mumble");
|
||||
expect(
|
||||
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.every((v) => v === "unassigned")
|
||||
).toBeTruthy();
|
||||
expect(xfltr1.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: expect.arrayContaining([
|
||||
"unassigned",
|
||||
"green",
|
||||
"blue",
|
||||
"mumble",
|
||||
]),
|
||||
});
|
||||
|
||||
// remove a used category
|
||||
const xfltr2 = await xfltr.removeObsAnnoCategory(
|
||||
"foo",
|
||||
"unassigned",
|
||||
"red"
|
||||
);
|
||||
expect(
|
||||
(await xfltr2.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.every((v) => v === "red")
|
||||
).toBeTruthy();
|
||||
expect(xfltr2.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: expect.arrayContaining(["green", "blue", "red"]),
|
||||
});
|
||||
});
|
||||
|
||||
test("setObsColumnValues", async () => {
|
||||
// catch unknown or readonly categories
|
||||
await expect(() =>
|
||||
crossfilter.setObsColumnValues("louvain", [0, 1], "unassigned")
|
||||
).rejects.toThrow("Unknown or readonly obs column");
|
||||
await expect(() =>
|
||||
crossfilter.setObsColumnValues("undefined-name", [0], "mumble")
|
||||
).rejects.toThrow("Unknown or readonly obs column");
|
||||
|
||||
let xfltr = await helperAddTestCol(crossfilter, "foo", {
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: ["unassigned", "red", "green", "blue"],
|
||||
});
|
||||
xfltr = await xfltr.select("obs", "foo", { mode: "all" });
|
||||
|
||||
// catch unknown row label
|
||||
await expect(() =>
|
||||
xfltr.setObsColumnValues("foo", [-1], "red")
|
||||
).rejects.toThrow("Unknown row label");
|
||||
|
||||
// set a few rows
|
||||
expect(
|
||||
(await xfltr.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.every((v) => v === "unassigned")
|
||||
).toBeTruthy();
|
||||
const xfltr1 = await xfltr.setObsColumnValues("foo", [0, 10], "purple");
|
||||
expect(
|
||||
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.every(
|
||||
(v, i) =>
|
||||
v === "unassigned" || (v === "purple" && (i === 0 || i === 10))
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(xfltr1.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: expect.arrayContaining([
|
||||
"unassigned",
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
"purple",
|
||||
]),
|
||||
});
|
||||
|
||||
expect(xfltr1.countSelected()).toEqual(xfltr1.annoMatrix.nObs);
|
||||
const xfltr2 = await xfltr1.select("obs", "foo", {
|
||||
mode: "exact",
|
||||
values: ["purple"],
|
||||
});
|
||||
expect(xfltr2.countSelected()).toEqual(2);
|
||||
expect(xfltr2.allSelectedLabels()).toEqual(Int32Array.from([0, 10]));
|
||||
});
|
||||
|
||||
test("resetObsColumnValues", async () => {
|
||||
// catch unknown or readonly categories
|
||||
await expect(() =>
|
||||
crossfilter.resetObsColumnValues("louvain", "red", "blue")
|
||||
).rejects.toThrow("Unknown or readonly obs column");
|
||||
await expect(() =>
|
||||
crossfilter.resetObsColumnValues("undefined-name", "red", "blue")
|
||||
).rejects.toThrow("Unknown or readonly obs column");
|
||||
|
||||
let xfltr = await helperAddTestCol(crossfilter, "foo", {
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: ["unassigned", "red", "green", "blue"],
|
||||
});
|
||||
xfltr = await xfltr.select("obs", "foo", {
|
||||
mode: "exact",
|
||||
values: "red",
|
||||
});
|
||||
|
||||
// catch unknown category name label
|
||||
await expect(() =>
|
||||
xfltr.resetObsColumnValues("foo", "unknown-label", "red")
|
||||
).rejects.toThrow("unknown category");
|
||||
|
||||
let xfltr1 = await xfltr.setObsColumnValues("foo", [0, 10], "purple");
|
||||
xfltr1 = await xfltr1.select("obs", "foo", {
|
||||
mode: "exact",
|
||||
values: "purple",
|
||||
});
|
||||
expect(
|
||||
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.filter((v) => v === "purple")
|
||||
).toHaveLength(2);
|
||||
|
||||
xfltr1 = await xfltr1.resetObsColumnValues("foo", "purple", "magenta");
|
||||
expect(
|
||||
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.filter((v) => v === "magenta")
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.filter((v) => v === "purple")
|
||||
).toHaveLength(0);
|
||||
expect(xfltr1.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: expect.arrayContaining([
|
||||
"unassigned",
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
"purple",
|
||||
"magenta",
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("transition from empty annoMatrix", async () => {
|
||||
// select before fetch needs to work
|
||||
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
|
||||
const xfltr = await crossfilter.select("obs", "louvain", {
|
||||
mode: "exact",
|
||||
values: "B cells",
|
||||
});
|
||||
expect(fetch.mock.calls).toHaveLength(1);
|
||||
expect(xfltr.obsCrossfilter.hasDimension("obs/louvain")).toBeTruthy();
|
||||
expect(xfltr.obsCrossfilter.all()).toBe(xfltr.annoMatrix._cache.obs);
|
||||
expect(xfltr.countSelected()).toEqual(
|
||||
obsLouvain.reduce(
|
||||
(count, v) => (v === "B cells" ? count + 1 : count),
|
||||
0
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
export const baseDataURL = "https://a.fake.url/api/v0.2";
|
||||
|
||||
window.CELLXGENE = {
|
||||
API: {
|
||||
prefix: baseDataURL,
|
||||
version: "v0.2/",
|
||||
},
|
||||
};
|
||||
|
||||
export { schema } from "./schema";
|
||||
export * from "./routes";
|
||||
@@ -0,0 +1,211 @@
|
||||
import { schema } from "./schema";
|
||||
import { Dataframe, KeyIndex } from "../../../../src/util/dataframe";
|
||||
import { encodeMatrixFBS } from "../../../../src/util/stateManager/matrix";
|
||||
|
||||
const indexedSchema = {
|
||||
obsByName: Object.fromEntries(
|
||||
schema.schema.annotations.obs.columns.map((v) => [v.name, v]) ?? []
|
||||
),
|
||||
varByName: Object.fromEntries(
|
||||
schema.schema.annotations.var.columns.map((v) => [v.name, v]) ?? []
|
||||
),
|
||||
embByName: Object.fromEntries(
|
||||
schema.schema.layout.obs.map((v) => [v.name, v]) ?? []
|
||||
),
|
||||
};
|
||||
|
||||
function makeMockColumn(s, length) {
|
||||
const { type } = s;
|
||||
switch (type) {
|
||||
case "int32":
|
||||
return new Int32Array(length).fill(Math.floor(99 * Math.random()));
|
||||
|
||||
case "string":
|
||||
return new Array(length).fill("test");
|
||||
|
||||
case "float32":
|
||||
return new Float32Array(length).fill(99 * Math.random());
|
||||
|
||||
case "boolean":
|
||||
return new Array(length).fill(false);
|
||||
|
||||
case "categorical":
|
||||
return new Array(length).fill(s.categories[0]);
|
||||
|
||||
default:
|
||||
throw new Error("unkonwn type");
|
||||
}
|
||||
}
|
||||
|
||||
function getEncodedDataframe(colNames, length, colSchemas) {
|
||||
const colIndex = new KeyIndex(colNames);
|
||||
const columns = colSchemas.map((s) => makeMockColumn(s, length));
|
||||
const df = new Dataframe([length, colNames.length], columns, null, colIndex);
|
||||
const body = encodeMatrixFBS(df);
|
||||
return body;
|
||||
}
|
||||
|
||||
export function dataframeResponse(colNames, columns) {
|
||||
const colIndex = new KeyIndex(colNames);
|
||||
const df = new Dataframe(
|
||||
[columns[0].length, colNames.length],
|
||||
columns,
|
||||
null,
|
||||
colIndex
|
||||
);
|
||||
const body = encodeMatrixFBS(df);
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/octet-stream",
|
||||
});
|
||||
return () => Promise.resolve({ body, init: { status: 200, headers } });
|
||||
}
|
||||
|
||||
function annotationObsResponse(request) {
|
||||
const url = new URL(request.url);
|
||||
const params = Array.from(url.searchParams.entries());
|
||||
const names = params
|
||||
.filter(([k]) => k === "annotation-name")
|
||||
.map(([, v]) => v);
|
||||
if (!names.every((n) => indexedSchema.obsByName[n])) {
|
||||
return Promise.reject(new Error("bad obs annotation name in URL"));
|
||||
}
|
||||
const colSchemas = names.map((n) => indexedSchema.obsByName[n]);
|
||||
const body = getEncodedDataframe(
|
||||
names,
|
||||
schema.schema.dataframe.nObs,
|
||||
colSchemas
|
||||
);
|
||||
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/octet-stream",
|
||||
});
|
||||
return Promise.resolve({
|
||||
body,
|
||||
init: { status: 200, headers },
|
||||
});
|
||||
}
|
||||
|
||||
function annotationVarResponse(request) {
|
||||
const url = new URL(request.url);
|
||||
const params = Array.from(url.searchParams.entries());
|
||||
const names = params
|
||||
.filter(([k]) => k === "annotation-name")
|
||||
.map(([, v]) => v);
|
||||
if (!names.every((n) => indexedSchema.varByName[n])) {
|
||||
return Promise.reject(new Error("bad var annotation name in URL"));
|
||||
}
|
||||
const colSchemas = names.map((n) => indexedSchema.varByName[n]);
|
||||
const body = getEncodedDataframe(
|
||||
names,
|
||||
schema.schema.dataframe.nVar,
|
||||
colSchemas
|
||||
);
|
||||
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/octet-stream",
|
||||
});
|
||||
return Promise.resolve({
|
||||
body,
|
||||
init: { status: 200, headers },
|
||||
});
|
||||
}
|
||||
|
||||
function layoutObsResponse(request) {
|
||||
const url = new URL(request.url);
|
||||
const params = Array.from(url.searchParams.entries());
|
||||
const names = params.filter(([k]) => k === "layout-name").map(([, v]) => v);
|
||||
if (!names.every((n) => indexedSchema.embByName[n])) {
|
||||
return Promise.reject(new Error("bad layout name in URL"));
|
||||
}
|
||||
const dims = names.map((n) => indexedSchema.embByName[n].dims).flat();
|
||||
const colSchemas = names
|
||||
.map((n) => [indexedSchema.embByName[n], indexedSchema.embByName[n]])
|
||||
.flat();
|
||||
const body = getEncodedDataframe(
|
||||
dims,
|
||||
schema.schema.dataframe.nObs,
|
||||
colSchemas
|
||||
);
|
||||
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/octet-stream",
|
||||
});
|
||||
return Promise.resolve({
|
||||
body,
|
||||
init: { status: 200, headers },
|
||||
});
|
||||
}
|
||||
|
||||
function dataVarResponse(request) {
|
||||
const url = new URL(request.url);
|
||||
const params = Array.from(url.searchParams.entries());
|
||||
|
||||
const colNames = params.map((v) => `${v[0]}/${v[1]}`);
|
||||
const colSchemas = colNames.map(() => schema.schema.dataframe);
|
||||
const body = getEncodedDataframe(
|
||||
colNames,
|
||||
schema.schema.dataframe.nObs,
|
||||
colSchemas
|
||||
);
|
||||
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/octet-stream",
|
||||
});
|
||||
return Promise.resolve({
|
||||
body,
|
||||
init: { status: 200, headers },
|
||||
});
|
||||
}
|
||||
|
||||
export function responder(request) {
|
||||
const url = new URL(request.url);
|
||||
const { pathname } = url;
|
||||
if (pathname.endsWith("/annotations/obs")) {
|
||||
return annotationObsResponse(request);
|
||||
}
|
||||
if (pathname.endsWith("/annotations/var")) {
|
||||
return annotationVarResponse(request);
|
||||
}
|
||||
if (pathname.endsWith("/layout/obs")) {
|
||||
return layoutObsResponse(request);
|
||||
}
|
||||
if (pathname.endsWith("/data/var")) {
|
||||
return dataVarResponse(request);
|
||||
}
|
||||
return Promise.reject(new Error("bad URL"));
|
||||
}
|
||||
|
||||
export function withExpected(expectedURL, expectedParams) {
|
||||
/*
|
||||
Do some additional error checking
|
||||
*/
|
||||
return (request) => {
|
||||
// if URL is bogus, reject the promise
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith(expectedURL)) {
|
||||
return Promise.reject(new Error("Unexpected URL!"));
|
||||
}
|
||||
const params = Array.from(url.searchParams.entries()).sort(
|
||||
(a, b) => a[0] < b[0]
|
||||
);
|
||||
expectedParams = expectedParams.slice().sort((a, b) => a[0] < b[0]);
|
||||
|
||||
if (
|
||||
params.length !== expectedParams.length ||
|
||||
!params.every(
|
||||
(p, i) => p[0] === expectedParams[i][0] && p[1] === expectedParams[i][1]
|
||||
)
|
||||
) {
|
||||
return Promise.reject(new Error("unexpected name requested in URL"));
|
||||
}
|
||||
|
||||
return responder(request);
|
||||
};
|
||||
}
|
||||
|
||||
export function annotationsObs(names) {
|
||||
return withExpected(
|
||||
"/annotations/obs",
|
||||
names.map((name) => ["annotation-name", name])
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export const schema = {
|
||||
schema: {
|
||||
annotations: {
|
||||
obs: {
|
||||
columns: [
|
||||
{
|
||||
name: "name_0",
|
||||
type: "string",
|
||||
writable: false,
|
||||
},
|
||||
{
|
||||
name: "n_genes",
|
||||
type: "int32",
|
||||
writable: false,
|
||||
},
|
||||
{
|
||||
name: "percent_mito",
|
||||
type: "float32",
|
||||
writable: false,
|
||||
},
|
||||
{
|
||||
name: "n_counts",
|
||||
type: "float32",
|
||||
writable: false,
|
||||
},
|
||||
{
|
||||
name: "louvain",
|
||||
type: "string",
|
||||
writable: false,
|
||||
},
|
||||
],
|
||||
index: "name_0",
|
||||
},
|
||||
var: {
|
||||
columns: [
|
||||
{
|
||||
name: "name_0",
|
||||
type: "string",
|
||||
writable: false,
|
||||
},
|
||||
{
|
||||
name: "n_cells",
|
||||
type: "int32",
|
||||
writable: false,
|
||||
},
|
||||
],
|
||||
index: "name_0",
|
||||
},
|
||||
},
|
||||
dataframe: {
|
||||
nObs: 2638,
|
||||
nVar: 1838,
|
||||
type: "float32",
|
||||
},
|
||||
layout: {
|
||||
obs: [
|
||||
{
|
||||
dims: ["draw_graph_fr_0", "draw_graph_fr_1"],
|
||||
name: "draw_graph_fr",
|
||||
type: "float32",
|
||||
},
|
||||
{
|
||||
dims: ["pca_0", "pca_1"],
|
||||
name: "pca",
|
||||
type: "float32",
|
||||
},
|
||||
{
|
||||
dims: ["tsne_0", "tsne_1"],
|
||||
name: "tsne",
|
||||
type: "float32",
|
||||
},
|
||||
{
|
||||
dims: ["umap_0", "umap_1"],
|
||||
name: "umap",
|
||||
type: "float32",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
_whereCacheGet,
|
||||
_whereCacheCreate,
|
||||
_whereCacheMerge,
|
||||
} from "../../../src/annoMatrix/whereCache";
|
||||
|
||||
const schema = {};
|
||||
|
||||
describe("whereCache", () => {
|
||||
test("whereCacheGet - missing cache values", () => {
|
||||
expect(
|
||||
_whereCacheGet({}, schema, "X", {
|
||||
field: "var",
|
||||
column: "foo",
|
||||
value: "bar",
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
expect(
|
||||
_whereCacheGet({ X: {} }, schema, "X", {
|
||||
field: "var",
|
||||
column: "foo",
|
||||
value: "bar",
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
expect(
|
||||
_whereCacheGet({ X: { var: new Map() } }, schema, "X", {
|
||||
field: "var",
|
||||
column: "foo",
|
||||
value: "bar",
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
expect(
|
||||
_whereCacheGet(
|
||||
{ X: { var: new Map([["foo", new Map()]]) } },
|
||||
schema,
|
||||
"X",
|
||||
{
|
||||
field: "var",
|
||||
column: "foo",
|
||||
value: "bar",
|
||||
}
|
||||
)
|
||||
).toEqual([undefined]);
|
||||
});
|
||||
|
||||
test("whereCacheGet - varied lookups", () => {
|
||||
const whereCache = {
|
||||
X: {
|
||||
var: new Map([
|
||||
[
|
||||
"foo",
|
||||
new Map([
|
||||
["bar", [0]],
|
||||
["baz", [1, 2]],
|
||||
]),
|
||||
],
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
_whereCacheGet(whereCache, schema, "X", {
|
||||
field: "var",
|
||||
column: "foo",
|
||||
value: "bar",
|
||||
})
|
||||
).toEqual([0]);
|
||||
expect(
|
||||
_whereCacheGet(whereCache, schema, "X", {
|
||||
field: "var",
|
||||
column: "foo",
|
||||
value: "baz",
|
||||
})
|
||||
).toEqual([1, 2]);
|
||||
expect(_whereCacheGet(whereCache, schema, "Y", {})).toEqual([undefined]);
|
||||
expect(
|
||||
_whereCacheGet(whereCache, schema, "X", {
|
||||
field: "whoknows",
|
||||
column: "whatever",
|
||||
value: "snork",
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
expect(
|
||||
_whereCacheGet(whereCache, schema, "X", {
|
||||
field: "var",
|
||||
column: "whatever",
|
||||
value: "snork",
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
expect(
|
||||
_whereCacheGet(whereCache, schema, "X", {
|
||||
field: "var",
|
||||
column: "foo",
|
||||
value: "snork",
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
});
|
||||
|
||||
test("whereCacheCreate", () => {
|
||||
const query = {
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
value: "queryValue",
|
||||
};
|
||||
const wc = _whereCacheCreate(
|
||||
"field",
|
||||
{ field: "queryField", column: "queryColumn", value: "queryValue" },
|
||||
[0, 1, 2]
|
||||
);
|
||||
expect(wc).toBeDefined();
|
||||
expect(wc).toEqual(
|
||||
expect.objectContaining({
|
||||
field: {
|
||||
queryField: expect.any(Map),
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(wc.field.queryField.has("queryColumn")).toEqual(true);
|
||||
expect(wc.field.queryField.get("queryColumn")).toBeInstanceOf(Map);
|
||||
expect(wc.field.queryField.get("queryColumn").has("queryValue")).toEqual(
|
||||
true
|
||||
);
|
||||
expect(_whereCacheGet(wc, schema, "field", query)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
test("whereCacheMerge", () => {
|
||||
let wc;
|
||||
|
||||
// remember, will mutate dst
|
||||
const src = _whereCacheCreate(
|
||||
"field",
|
||||
{ field: "queryField", column: "queryColumn", value: "foo" },
|
||||
["foo"]
|
||||
);
|
||||
|
||||
const dst1 = _whereCacheCreate(
|
||||
"field",
|
||||
{ field: "queryField", column: "queryColumn", value: "bar" },
|
||||
["dst1"]
|
||||
);
|
||||
wc = _whereCacheMerge(dst1, src);
|
||||
expect(
|
||||
_whereCacheGet(wc, schema, "field", {
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
value: "foo",
|
||||
})
|
||||
).toEqual(["foo"]);
|
||||
expect(
|
||||
_whereCacheGet(wc, schema, "field", {
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
value: "bar",
|
||||
})
|
||||
).toEqual(["dst1"]);
|
||||
|
||||
const dst2 = _whereCacheCreate(
|
||||
"field",
|
||||
{ field: "queryField", column: "queryColumn", value: "bar" },
|
||||
["dst2"]
|
||||
);
|
||||
wc = _whereCacheMerge(dst2, dst1, src);
|
||||
expect(
|
||||
_whereCacheGet(wc, schema, "field", {
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
value: "foo",
|
||||
})
|
||||
).toEqual(["foo"]);
|
||||
expect(
|
||||
_whereCacheGet(wc, schema, "field", {
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
value: "bar",
|
||||
})
|
||||
).toEqual(["dst1"]);
|
||||
|
||||
wc = _whereCacheMerge({}, src);
|
||||
expect(wc).toEqual(src);
|
||||
|
||||
wc = _whereCacheMerge({ field: { queryField: new Map() } }, src);
|
||||
expect(wc).toEqual(src);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,36 @@
|
||||
import _ from "lodash";
|
||||
|
||||
import calcCentroid from "../../src/util/centroid";
|
||||
|
||||
import quantile from "../../src/util/quantile";
|
||||
import * as Universe from "../../src/util/stateManager/universe";
|
||||
import { matrixFBSToDataframe } from "../../src/util/stateManager/matrix";
|
||||
import * as World from "../../src/util/stateManager/world";
|
||||
import * as REST from "./stateManager/sampleResponses";
|
||||
import { indexEntireSchema } from "../../src/util/stateManager/schemaHelpers";
|
||||
import { _normalizeCategoricalSchema } from "../../src/annoMatrix/schema";
|
||||
|
||||
describe("centroid", () => {
|
||||
let world;
|
||||
let schema;
|
||||
let obsAnnotations;
|
||||
let obsLayout;
|
||||
|
||||
beforeAll(() => {
|
||||
// Create world + universe
|
||||
let universe = Universe.createUniverseFromResponse(
|
||||
_.cloneDeep(REST.config),
|
||||
_.cloneDeep(REST.schema)
|
||||
);
|
||||
schema = indexEntireSchema(_.cloneDeep(REST.schema.schema));
|
||||
obsAnnotations = matrixFBSToDataframe(REST.annotationsObs);
|
||||
obsLayout = matrixFBSToDataframe(REST.layoutObs);
|
||||
|
||||
universe = {
|
||||
...universe,
|
||||
...Universe.addObsAnnotations(
|
||||
universe,
|
||||
matrixFBSToDataframe(REST.annotationsObs)
|
||||
),
|
||||
...Universe.addVarAnnotations(
|
||||
universe,
|
||||
matrixFBSToDataframe(REST.annotationsVar)
|
||||
),
|
||||
...Universe.addObsLayout(universe, matrixFBSToDataframe(REST.layoutObs)),
|
||||
};
|
||||
world = World.createWorldFromEntireUniverse(universe);
|
||||
_normalizeCategoricalSchema(
|
||||
schema.annotations.obsByName.field3,
|
||||
obsAnnotations.col("field3")
|
||||
);
|
||||
});
|
||||
|
||||
test("field4 (categorical obsAnnotation)", () => {
|
||||
const centroidResult = calcCentroid(world, "field4", ["umap_0", "umap_1"]);
|
||||
const centroidResult = calcCentroid(
|
||||
schema,
|
||||
"field4",
|
||||
obsAnnotations,
|
||||
{ current: "umap", currentDimNames: ["umap_0", "umap_1"] },
|
||||
obsLayout
|
||||
);
|
||||
|
||||
// Check to see that a centroid has been calculated for every categorical value
|
||||
const keysAsArray = Array.from(centroidResult.keys());
|
||||
@@ -44,8 +40,8 @@ describe("centroid", () => {
|
||||
|
||||
// This expected result assumes that all cells belong in all categorical values inside of sample response
|
||||
const expectedResult = [
|
||||
quantile([0.5], world.obsLayout.col("umap_0").asArray())[0],
|
||||
quantile([0.5], world.obsLayout.col("umap_1").asArray())[0],
|
||||
quantile([0.5], obsLayout.col("umap_0").asArray())[0],
|
||||
quantile([0.5], obsLayout.col("umap_1").asArray())[0],
|
||||
];
|
||||
|
||||
centroidResult.forEach((coordinate) => {
|
||||
@@ -54,7 +50,13 @@ describe("centroid", () => {
|
||||
});
|
||||
|
||||
test("field3 (boolean obsAnnotation)", () => {
|
||||
const centroidResult = calcCentroid(world, "field3", ["umap_0", "umap_1"]);
|
||||
const centroidResult = calcCentroid(
|
||||
schema,
|
||||
"field3",
|
||||
obsAnnotations,
|
||||
{ current: "umap", currentDimNames: ["umap_0", "umap_1"] },
|
||||
obsLayout
|
||||
);
|
||||
|
||||
// Check to see that a centroid has been calculated for every categorical value
|
||||
const keysAsArray = Array.from(centroidResult.keys());
|
||||
@@ -62,8 +64,8 @@ describe("centroid", () => {
|
||||
|
||||
// This expected result assumes that all cells belong in all categorical values inside of sample response
|
||||
const expectedResult = [
|
||||
quantile([0.5], world.obsLayout.col("umap_0").asArray())[0],
|
||||
quantile([0.5], world.obsLayout.col("umap_1").asArray())[0],
|
||||
quantile([0.5], obsLayout.col("umap_0").asArray())[0],
|
||||
quantile([0.5], obsLayout.col("umap_1").asArray())[0],
|
||||
];
|
||||
|
||||
centroidResult.forEach((coordinate) => {
|
||||
|
||||
@@ -918,115 +918,342 @@ describe("dataframe col", () => {
|
||||
});
|
||||
|
||||
describe("label indexing", () => {
|
||||
test("IdentityInt32Index", () => {
|
||||
describe("isLabelIndex", () => {
|
||||
expect(
|
||||
Dataframe.isLabelIndex(new Dataframe.IdentityInt32Index(4))
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
Dataframe.isLabelIndex(new Dataframe.DenseInt32Index([2, 4, 99]))
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
Dataframe.isLabelIndex(new Dataframe.KeyIndex(["a", 4, "toasty"]))
|
||||
).toBeTruthy();
|
||||
expect(Dataframe.isLabelIndex(false)).toBeFalsy();
|
||||
expect(Dataframe.isLabelIndex(undefined)).toBeFalsy();
|
||||
expect(Dataframe.isLabelIndex(null)).toBeFalsy();
|
||||
expect(Dataframe.isLabelIndex(true)).toBeFalsy();
|
||||
expect(Dataframe.isLabelIndex([])).toBeFalsy();
|
||||
expect(Dataframe.isLabelIndex({})).toBeFalsy();
|
||||
expect(Dataframe.isLabelIndex(Dataframe.IdentityInt32Index)).toBeFalsy();
|
||||
});
|
||||
|
||||
describe("IdentityInt32Index", () => {
|
||||
const idx = new Dataframe.IdentityInt32Index(12); // [0, 12)
|
||||
|
||||
expect(Dataframe.isLabelIndex(idx)).toBeTruthy();
|
||||
test("create", () => {
|
||||
expect(Dataframe.isLabelIndex(idx)).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(idx.labels()).toEqual(
|
||||
new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||
);
|
||||
expect(idx.getLabel(1)).toEqual(1);
|
||||
expect(idx.getOffset(1)).toEqual(1);
|
||||
expect(idx.getOffsets([1, 3])).toEqual([1, 3]);
|
||||
expect(idx.getLabels([1, 3])).toEqual([1, 3]);
|
||||
expect(idx.size()).toEqual(12);
|
||||
test("labels", () => {
|
||||
expect(idx.labels()).toEqual(
|
||||
new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||
);
|
||||
expect(idx.getLabel(1)).toEqual(1);
|
||||
expect(idx.getLabels([1, 3])).toEqual([1, 3]);
|
||||
expect(idx.size()).toEqual(12);
|
||||
});
|
||||
|
||||
expect(idx.subset([2]).labels()).toEqual([2]);
|
||||
expect(idx.subset([2, 3, 4]).labels()).toEqual(new Int32Array([2, 3, 4]));
|
||||
expect(idx.subset([0, 1, 2, 3]).labels()).toEqual(
|
||||
new Int32Array([0, 1, 2, 3])
|
||||
);
|
||||
test("offsets", () => {
|
||||
expect(idx.getOffset(1)).toEqual(1);
|
||||
expect(idx.getOffsets([1, 3])).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
expect(idx.isubset([2]).labels()).toEqual([2]);
|
||||
expect(idx.isubset([2, 3, 4]).labels()).toEqual(new Int32Array([2, 3, 4]));
|
||||
expect(idx.isubset([0, 1, 2, 3]).labels()).toEqual(
|
||||
new Int32Array([0, 1, 2, 3])
|
||||
);
|
||||
test("subset", () => {
|
||||
expect(idx.subset([2]).labels()).toEqual([2]);
|
||||
expect(idx.subset([2, 3, 4]).labels()).toEqual(new Int32Array([2, 3, 4]));
|
||||
expect(idx.subset([0, 1, 2, 3]).labels()).toEqual(
|
||||
new Int32Array([0, 1, 2, 3])
|
||||
);
|
||||
expect(idx.subset([0, 1, 2, 3, 4])).toBeInstanceOf(
|
||||
Dataframe.IdentityInt32Index
|
||||
);
|
||||
expect(idx.subset([2, 1, 0])).toBeInstanceOf(
|
||||
Dataframe.IdentityInt32Index
|
||||
);
|
||||
expect(idx.subset([1, 2, 3, 4])).toBeInstanceOf(
|
||||
Dataframe.DenseInt32Index
|
||||
);
|
||||
expect(idx.subset([0, 1, 3, 4])).toBeInstanceOf(
|
||||
Dataframe.DenseInt32Index
|
||||
);
|
||||
expect(idx.subset([0, 1, 2, 3, 10])).toBeInstanceOf(
|
||||
Dataframe.DenseInt32Index
|
||||
);
|
||||
expect(idx.subset([4, 3, 2, 1])).toBeInstanceOf(
|
||||
Dataframe.DenseInt32Index
|
||||
);
|
||||
expect(idx.subset([4])).toBeInstanceOf(Dataframe.KeyIndex);
|
||||
});
|
||||
|
||||
expect(idx.subset([0, 1, 2, 3, 4])).toBeInstanceOf(
|
||||
Dataframe.IdentityInt32Index
|
||||
);
|
||||
expect(idx.subset([2, 1, 0])).toBeInstanceOf(Dataframe.IdentityInt32Index);
|
||||
expect(idx.subset([1, 2, 3, 4])).toBeInstanceOf(Dataframe.DenseInt32Index);
|
||||
expect(idx.subset([0, 1, 3, 4])).toBeInstanceOf(Dataframe.DenseInt32Index);
|
||||
expect(idx.subset([0, 1, 2, 3, 10])).toBeInstanceOf(
|
||||
Dataframe.DenseInt32Index
|
||||
);
|
||||
expect(idx.subset([4, 3, 2, 1])).toBeInstanceOf(Dataframe.DenseInt32Index);
|
||||
expect(idx.subset([4])).toBeInstanceOf(Dataframe.KeyIndex);
|
||||
test("isubset", () => {
|
||||
expect(idx.isubset([2]).labels()).toEqual([2]);
|
||||
expect(idx.isubset([2, 3, 4]).labels()).toEqual(
|
||||
new Int32Array([2, 3, 4])
|
||||
);
|
||||
expect(idx.isubset([0, 1, 2, 3]).labels()).toEqual(
|
||||
new Int32Array([0, 1, 2, 3])
|
||||
);
|
||||
expect(() => idx.isubset([-1001])).toThrow(RangeError);
|
||||
expect(() => idx.isubset([1001])).toThrow(RangeError);
|
||||
});
|
||||
|
||||
expect(idx.withLabel(99).labels()).toEqual(
|
||||
new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 99])
|
||||
);
|
||||
expect(idx.dropLabel(0).labels()).toEqual(
|
||||
new Int32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||
);
|
||||
expect(idx.dropLabel(11).labels()).toEqual(
|
||||
new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
|
||||
);
|
||||
expect(idx.dropLabel(5).labels()).toEqual(
|
||||
new Int32Array([0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11])
|
||||
);
|
||||
test("isubsetMask", () => {
|
||||
expect(
|
||||
idx
|
||||
.isubsetMask([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
])
|
||||
.labels()
|
||||
).toEqual(new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]));
|
||||
expect(
|
||||
idx
|
||||
.isubsetMask([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
])
|
||||
.labels()
|
||||
).toEqual(new Int32Array([]));
|
||||
expect(
|
||||
idx
|
||||
.isubsetMask([
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
])
|
||||
.labels()
|
||||
).toEqual(new Int32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]));
|
||||
expect(
|
||||
idx
|
||||
.isubsetMask([
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
])
|
||||
.labels()
|
||||
).toEqual(new Int32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 11]));
|
||||
expect(
|
||||
idx
|
||||
.isubsetMask([
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
])
|
||||
.labels()
|
||||
).toEqual(new Int32Array([1, 2, 4, 5, 6, 7, 8, 9, 10]));
|
||||
expect(() => idx.isubsetMask([])).toThrow(RangeError);
|
||||
});
|
||||
|
||||
test("withLabel", () => {
|
||||
expect(idx.withLabel(99).labels()).toEqual(
|
||||
new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 99])
|
||||
);
|
||||
expect(idx.withLabel(12).labels()).toEqual(
|
||||
new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
|
||||
);
|
||||
expect(idx.withLabels([12, 13]).labels()).toEqual(
|
||||
new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13])
|
||||
);
|
||||
});
|
||||
|
||||
test("dropLabel", () => {
|
||||
expect(idx.dropLabel(0).labels()).toEqual(
|
||||
new Int32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||
);
|
||||
expect(idx.dropLabel(11).labels()).toEqual(
|
||||
new Int32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
|
||||
);
|
||||
expect(idx.dropLabel(5).labels()).toEqual(
|
||||
new Int32Array([0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("DenseInt32Index", () => {
|
||||
describe("DenseInt32Index", () => {
|
||||
const idx = new Dataframe.DenseInt32Index([99, 1002, 48, 0, 22]);
|
||||
|
||||
expect(Dataframe.isLabelIndex(idx)).toBeTruthy();
|
||||
test("create", () => {
|
||||
expect(Dataframe.isLabelIndex(idx)).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(idx.labels()).toEqual(new Int32Array([99, 1002, 48, 0, 22]));
|
||||
expect(idx.size()).toEqual(5);
|
||||
expect(idx.getOffset(1002)).toEqual(1);
|
||||
expect(idx.getOffset(0)).toEqual(3);
|
||||
expect(idx.getLabel(0)).toEqual(99);
|
||||
expect(idx.getLabels(new Int32Array([2, 4]))).toEqual(
|
||||
new Int32Array([48, 22])
|
||||
);
|
||||
expect(idx.getLabels([2, 4])).toEqual([48, 22]);
|
||||
expect(idx.getOffsets([0, 48])).toEqual([3, 2]);
|
||||
test("labels", () => {
|
||||
expect(idx.labels()).toEqual(new Int32Array([99, 1002, 48, 0, 22]));
|
||||
expect(idx.size()).toEqual(5);
|
||||
expect(idx.getLabel(0)).toEqual(99);
|
||||
expect(idx.getLabels(new Int32Array([2, 4]))).toEqual(
|
||||
new Int32Array([48, 22])
|
||||
);
|
||||
expect(idx.getLabels([2, 4])).toEqual([48, 22]);
|
||||
});
|
||||
|
||||
expect(idx.subset([1002, 0, 99]).labels()).toEqual(
|
||||
new Int32Array([1002, 0, 99])
|
||||
);
|
||||
expect(idx.getOffsets(idx.subset([1002, 0, 99]).labels())).toEqual(
|
||||
new Int32Array([1, 3, 0])
|
||||
);
|
||||
expect(idx.isubset([4, 1, 2]).labels()).toEqual(
|
||||
new Int32Array([22, 1002, 48])
|
||||
);
|
||||
test("offsets", () => {
|
||||
expect(idx.getOffset(1002)).toEqual(1);
|
||||
expect(idx.getOffset(0)).toEqual(3);
|
||||
expect(idx.getOffsets([0, 48])).toEqual([3, 2]);
|
||||
});
|
||||
|
||||
expect(idx.withLabel(88).labels()).toEqual(
|
||||
new Int32Array([99, 1002, 48, 0, 22, 88])
|
||||
);
|
||||
expect(idx.withLabel(88).getOffset(88)).toEqual(5);
|
||||
expect(idx.dropLabel(48).labels()).toEqual(
|
||||
new Int32Array([99, 1002, 0, 22])
|
||||
);
|
||||
test("subset", () => {
|
||||
expect(idx.subset([1002, 0, 99]).labels()).toEqual(
|
||||
new Int32Array([1002, 0, 99])
|
||||
);
|
||||
expect(idx.getOffsets(idx.subset([1002, 0, 99]).labels())).toEqual(
|
||||
new Int32Array([1, 3, 0])
|
||||
);
|
||||
expect(() => idx.subset([-1])).toThrow(RangeError);
|
||||
});
|
||||
|
||||
test("isubset", () => {
|
||||
expect(idx.isubset([4, 1, 2]).labels()).toEqual(
|
||||
new Int32Array([22, 1002, 48])
|
||||
);
|
||||
expect(() => idx.isubset([-1001])).toThrow(RangeError);
|
||||
expect(() => idx.isubset([1001])).toThrow(RangeError);
|
||||
});
|
||||
|
||||
test("isubsetMask", () => {
|
||||
expect(idx.isubsetMask([true, true, true, true, true]).labels()).toEqual(
|
||||
new Int32Array([99, 1002, 48, 0, 22])
|
||||
);
|
||||
expect(
|
||||
idx.isubsetMask([false, false, false, false, false]).labels()
|
||||
).toEqual(new Int32Array([]));
|
||||
expect(idx.isubsetMask([true, true, false, true, true]).labels()).toEqual(
|
||||
new Int32Array([99, 1002, 0, 22])
|
||||
);
|
||||
expect(
|
||||
idx.isubsetMask([false, true, true, true, false]).labels()
|
||||
).toEqual(new Int32Array([1002, 48, 0]));
|
||||
expect(() => idx.isubsetMask([])).toThrow(RangeError);
|
||||
});
|
||||
|
||||
test("withLabel", () => {
|
||||
expect(idx.withLabel(88).labels()).toEqual(
|
||||
new Int32Array([99, 1002, 48, 0, 22, 88])
|
||||
);
|
||||
expect(idx.withLabel(88).getOffset(88)).toEqual(5);
|
||||
expect(idx.withLabels([88, 99]).labels()).toEqual(
|
||||
new Int32Array([99, 1002, 48, 0, 22, 88, 99])
|
||||
);
|
||||
});
|
||||
test("dropLabel", () => {
|
||||
expect(idx.dropLabel(48).labels()).toEqual(
|
||||
new Int32Array([99, 1002, 0, 22])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("KeyIndex", () => {
|
||||
describe("KeyIndex", () => {
|
||||
const idx = new Dataframe.KeyIndex(["red", "green", "blue"]);
|
||||
|
||||
expect(Dataframe.isLabelIndex(idx)).toBeTruthy();
|
||||
test("create", () => {
|
||||
expect(Dataframe.isLabelIndex(idx)).toBeTruthy();
|
||||
expect(() => new Dataframe.KeyIndex(["dup", "dup"])).toThrow(Error);
|
||||
expect(new Dataframe.KeyIndex().size()).toEqual(0);
|
||||
});
|
||||
|
||||
expect(idx.labels()).toEqual(["red", "green", "blue"]);
|
||||
expect(idx.size()).toEqual(3);
|
||||
expect(idx.getOffset("blue")).toEqual(2);
|
||||
expect(idx.getLabel(1)).toEqual("green");
|
||||
test("labels", () => {
|
||||
expect(idx.labels()).toEqual(["red", "green", "blue"]);
|
||||
expect(idx.size()).toEqual(3);
|
||||
expect(idx.getLabel(1)).toEqual("green");
|
||||
expect(idx.getLabels([2, 0])).toEqual(["blue", "red"]);
|
||||
});
|
||||
|
||||
expect(idx.subset(["green"]).labels()).toEqual(["green"]);
|
||||
expect(idx.subset(["green", "red"]).labels()).toEqual(["green", "red"]);
|
||||
expect(idx.isubset([2, 1, 0]).labels()).toEqual(["blue", "green", "red"]);
|
||||
test("offsets", () => {
|
||||
expect(idx.getOffset("blue")).toEqual(2);
|
||||
});
|
||||
|
||||
expect(idx.withLabel("yo").labels()).toEqual([
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
"yo",
|
||||
]);
|
||||
expect(idx.withLabel("yo").getOffset("yo")).toEqual(3);
|
||||
expect(idx.dropLabel("blue").labels()).toEqual(["red", "green"]);
|
||||
test("subset", () => {
|
||||
expect(idx.subset(["green"]).labels()).toEqual(["green"]);
|
||||
expect(idx.subset(["green", "red"]).labels()).toEqual(["green", "red"]);
|
||||
});
|
||||
|
||||
test("isubset", () => {
|
||||
expect(idx.isubset([2, 1, 0]).labels()).toEqual(["blue", "green", "red"]);
|
||||
expect(() => idx.isubset([-1001])).toThrow(RangeError);
|
||||
expect(() => idx.isubset([1001])).toThrow(RangeError);
|
||||
});
|
||||
|
||||
test("isubsetMask", () => {
|
||||
expect(idx.isubsetMask([true, true, true]).labels()).toEqual([
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
]);
|
||||
expect(idx.isubsetMask([false, false, false]).labels()).toEqual([]);
|
||||
expect(idx.isubsetMask([true, false, true]).labels()).toEqual([
|
||||
"red",
|
||||
"blue",
|
||||
]);
|
||||
expect(() => idx.isubsetMask([])).toThrow(RangeError);
|
||||
});
|
||||
|
||||
test("withLabel", () => {
|
||||
expect(idx.withLabel("yo").labels()).toEqual([
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
"yo",
|
||||
]);
|
||||
expect(idx.withLabel("yo").getOffset("yo")).toEqual(3);
|
||||
expect(idx.withLabels(["hey", "there"]).labels()).toEqual([
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
"hey",
|
||||
"there",
|
||||
]);
|
||||
});
|
||||
|
||||
test("dropLabel", () => {
|
||||
expect(idx.dropLabel("blue").labels()).toEqual(["red", "green"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -71,4 +71,26 @@ describe("PromiseLimit", () => {
|
||||
]);
|
||||
expect(result).toEqual(["OK", "not OK", "OK", "not OK"]);
|
||||
});
|
||||
|
||||
test("priority queue", async () => {
|
||||
const plimit = new PromiseLimit(1);
|
||||
|
||||
let finishOrder = 0;
|
||||
const callback = () => async () => {
|
||||
await delay(100);
|
||||
const result = finishOrder;
|
||||
finishOrder += 1;
|
||||
return result;
|
||||
};
|
||||
|
||||
const result = await Promise.all([
|
||||
plimit.add(callback()),
|
||||
plimit.priorityAdd(4, callback()),
|
||||
plimit.priorityAdd(0, callback()),
|
||||
plimit.priorityAdd(1, callback()),
|
||||
plimit.priorityAdd(-1, callback()),
|
||||
]);
|
||||
|
||||
expect(result).toEqual([0, 4, 2, 3, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import * as Universe from "../../../src/util/stateManager/universe";
|
||||
import { matrixFBSToDataframe } from "../../../src/util/stateManager/matrix";
|
||||
import * as Dataframe from "../../../src/util/dataframe";
|
||||
import * as REST from "./sampleResponses";
|
||||
|
||||
describe("createUniverseFromResponse", () => {
|
||||
/*
|
||||
test createUniverseFromResponse - this function converts
|
||||
a set of REST 0.2 responses into a "new" Universe.
|
||||
|
||||
createUniverseFromResponse(
|
||||
configResponse,
|
||||
schemaResponse,
|
||||
annotationsObsResponse,
|
||||
annotationsVarResponse,
|
||||
layoutObsResponse
|
||||
) --> Universe
|
||||
|
||||
where:
|
||||
configResponse: GET /.../config
|
||||
schemaResponse: GET /.../schema
|
||||
annotationsObsResponse: GET /.../annotations/obs
|
||||
annotationsVarResponse: GET /.../annotations/var
|
||||
layoutObsResponse: GET /.../layout/obs
|
||||
|
||||
See spec in docs/REST_API.md.
|
||||
*/
|
||||
|
||||
test("create from test data", () => {
|
||||
/*
|
||||
create a universe from sample data nad validate its shape & contents
|
||||
*/
|
||||
const { nObs, nVar } = REST.schema.schema.dataframe;
|
||||
let universe = Universe.createUniverseFromResponse(
|
||||
REST.config,
|
||||
REST.schema
|
||||
);
|
||||
expect(universe).toBeDefined();
|
||||
expect(universe).toMatchObject(
|
||||
expect.objectContaining({
|
||||
nObs,
|
||||
nVar,
|
||||
schema: REST.schema.schema,
|
||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||
varAnnotations: expect.any(Dataframe.Dataframe),
|
||||
obsLayout: expect.any(Dataframe.Dataframe),
|
||||
varData: expect.any(Dataframe.Dataframe),
|
||||
})
|
||||
);
|
||||
|
||||
universe = {
|
||||
...universe,
|
||||
...Universe.addObsAnnotations(
|
||||
universe,
|
||||
matrixFBSToDataframe(REST.annotationsObs)
|
||||
),
|
||||
...Universe.addVarAnnotations(
|
||||
universe,
|
||||
matrixFBSToDataframe(REST.annotationsVar)
|
||||
),
|
||||
...Universe.addObsLayout(universe, matrixFBSToDataframe(REST.layoutObs)),
|
||||
};
|
||||
|
||||
expect(universe).toMatchObject(
|
||||
expect.objectContaining({
|
||||
nObs,
|
||||
nVar,
|
||||
schema: REST.schema.schema,
|
||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||
varAnnotations: expect.any(Dataframe.Dataframe),
|
||||
obsLayout: expect.any(Dataframe.Dataframe),
|
||||
varData: expect.any(Dataframe.Dataframe),
|
||||
})
|
||||
);
|
||||
|
||||
expect(universe.obsAnnotations.dims).toEqual([
|
||||
nObs,
|
||||
REST.schema.schema.annotations.obs.columns.length,
|
||||
]);
|
||||
expect(universe.obsLayout.dims).toEqual([nObs, 2]);
|
||||
expect(universe.obsLayout.colIndex.labels()).toEqual(
|
||||
universe.schema.layout.obs[0].dims
|
||||
);
|
||||
expect(universe.varAnnotations.dims).toEqual([
|
||||
nVar,
|
||||
REST.schema.schema.annotations.var.columns.length,
|
||||
]);
|
||||
expect(universe.varData.isEmpty()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
import _ from "lodash";
|
||||
import * as Universe from "../../../src/util/stateManager/universe";
|
||||
import { matrixFBSToDataframe } from "../../../src/util/stateManager/matrix";
|
||||
import * as World from "../../../src/util/stateManager/world";
|
||||
import * as Dataframe from "../../../src/util/dataframe";
|
||||
import Crossfilter from "../../../src/util/typedCrossfilter";
|
||||
import { DimTypes } from "../../../src/util/typedCrossfilter/crossfilter";
|
||||
import * as REST from "./sampleResponses";
|
||||
import {
|
||||
obsAnnoDimensionName,
|
||||
layoutDimensionName,
|
||||
} from "../../../src/util/nameCreators";
|
||||
|
||||
/*
|
||||
Helper - creates universe, world, corssfilter and dimensionMap from
|
||||
the default REST test response.
|
||||
*/
|
||||
const defaultBigBang = () => {
|
||||
/* create unverse, world, crossfilter and dimensionMap */
|
||||
/* create universe */
|
||||
let universe = Universe.createUniverseFromResponse(
|
||||
_.cloneDeep(REST.config),
|
||||
_.cloneDeep(REST.schema)
|
||||
);
|
||||
|
||||
universe = {
|
||||
...universe,
|
||||
...Universe.addObsAnnotations(
|
||||
universe,
|
||||
matrixFBSToDataframe(REST.annotationsObs)
|
||||
),
|
||||
...Universe.addVarAnnotations(
|
||||
universe,
|
||||
matrixFBSToDataframe(REST.annotationsVar)
|
||||
),
|
||||
...Universe.addObsLayout(universe, matrixFBSToDataframe(REST.layoutObs)),
|
||||
};
|
||||
|
||||
/* create world */
|
||||
const world = World.createWorldFromEntireUniverse(universe);
|
||||
/* create crossfilter */
|
||||
const crossfilter = World.createObsDimensions(
|
||||
new Crossfilter(world.obsAnnotations),
|
||||
world,
|
||||
REST.schema.schema.layout.obs[0].dims
|
||||
);
|
||||
|
||||
return {
|
||||
universe,
|
||||
world,
|
||||
crossfilter,
|
||||
};
|
||||
};
|
||||
|
||||
describe("createWorldFromEntireUniverse", () => {
|
||||
test("create from REST sample", () => {
|
||||
const universe = Universe.createUniverseFromResponse(
|
||||
_.cloneDeep(REST.config),
|
||||
_.cloneDeep(REST.schema),
|
||||
matrixFBSToDataframe(_.cloneDeep(REST.annotationsObs)),
|
||||
matrixFBSToDataframe(_.cloneDeep(REST.annotationsVar)),
|
||||
matrixFBSToDataframe(_.cloneDeep(REST.layoutObs))
|
||||
);
|
||||
expect(universe).toBeDefined();
|
||||
|
||||
const world = World.createWorldFromEntireUniverse(universe);
|
||||
expect(world).toBeDefined();
|
||||
|
||||
expect(world).toMatchObject(
|
||||
expect.objectContaining({
|
||||
nObs: universe.nObs,
|
||||
nVar: universe.nVar,
|
||||
schema: universe.schema,
|
||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||
varAnnotations: expect.any(Dataframe.Dataframe),
|
||||
obsLayout: expect.any(Dataframe.Dataframe),
|
||||
varData: expect.any(Dataframe.Dataframe),
|
||||
clipQuantiles: { min: 0, max: 1 },
|
||||
unclipped: {
|
||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||
varData: expect.any(Dataframe.Dataframe),
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWorldFromCurrentSelection", () => {
|
||||
test("create from REST sample", () => {
|
||||
const {
|
||||
universe,
|
||||
world: originalWorld,
|
||||
crossfilter: originalCrossfilter,
|
||||
} = defaultBigBang();
|
||||
|
||||
/* mock a selection */
|
||||
const crossfilter = originalCrossfilter
|
||||
.select(obsAnnoDimensionName("field1"), { mode: "range", lo: 0, hi: 5 })
|
||||
.select(obsAnnoDimensionName("field3"), {
|
||||
mode: "exact",
|
||||
values: [false],
|
||||
});
|
||||
|
||||
/* create the world from the selection */
|
||||
const world = World.createWorldBySelection(
|
||||
universe,
|
||||
originalWorld,
|
||||
crossfilter
|
||||
);
|
||||
expect(world).toBeDefined();
|
||||
expect(world.nObs).toEqual(crossfilter.countSelected());
|
||||
|
||||
/*
|
||||
calculate expected values and match against result
|
||||
*/
|
||||
|
||||
/* matchFilter must match the dimension filters above */
|
||||
const matchFilter = (df, row) => {
|
||||
const field1 = df.at(row, "field1");
|
||||
const field3 = df.at(row, "field3");
|
||||
return field1 >= 0 && field1 < 5 && !field3;
|
||||
};
|
||||
const matchingIndices = _()
|
||||
.range(universe.nObs)
|
||||
.filter((idx) => matchFilter(universe.obsAnnotations, idx))
|
||||
.value();
|
||||
|
||||
expect(world).toMatchObject(
|
||||
expect.objectContaining({
|
||||
nObs: matchingIndices.length,
|
||||
nVar: universe.nVar,
|
||||
schema: universe.schema,
|
||||
clipQuantiles: { min: 0, max: 1 },
|
||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||
varAnnotations: expect.any(Dataframe.Dataframe),
|
||||
obsLayout: expect.any(Dataframe.Dataframe),
|
||||
varData: expect.any(Dataframe.Dataframe),
|
||||
unclipped: {
|
||||
obsAnnotations: expect.any(Dataframe.Dataframe),
|
||||
varData: expect.any(Dataframe.Dataframe),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(world.obsAnnotations.rowIndex.labels()).toEqual(
|
||||
new Int32Array(matchingIndices)
|
||||
);
|
||||
expect(world.obsAnnotations.colIndex.labels()).toEqual(
|
||||
universe.obsAnnotations.colIndex.labels()
|
||||
);
|
||||
expect(world.obsLayout.rowIndex.labels()).toEqual(
|
||||
new Int32Array(matchingIndices)
|
||||
);
|
||||
expect(world.obsLayout.colIndex.labels()).toEqual(
|
||||
world.schema.layout.obs[0].dims
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createObsDimensionMap", () => {
|
||||
test("when universe eq world", () => {
|
||||
/*
|
||||
check for:
|
||||
- creates a dimension for all obsAnnotations, PLUS X/Y layout
|
||||
- check that dimension typing is sane
|
||||
*/
|
||||
|
||||
const { crossfilter } = defaultBigBang();
|
||||
const annotationNames = _.map(
|
||||
REST.schema.schema.annotations.obs.columns,
|
||||
(c) => c.name
|
||||
);
|
||||
const obsIndexColName = REST.schema.schema.annotations.obs.index;
|
||||
const schemaByObsName = _.keyBy(
|
||||
REST.schema.schema.annotations.obs.columns,
|
||||
"name"
|
||||
);
|
||||
expect(crossfilter).toBeDefined();
|
||||
annotationNames.forEach((name) => {
|
||||
const dim = crossfilter.dimensions[obsAnnoDimensionName(name)];
|
||||
if (name === obsIndexColName) {
|
||||
expect(dim).toBeUndefined();
|
||||
} else {
|
||||
const { type } = schemaByObsName[name];
|
||||
if (type === "string" || type === "boolean" || type === "categorical") {
|
||||
expect(dim.dim).toBeInstanceOf(DimTypes.enum);
|
||||
} else {
|
||||
expect(dim.dim).toBeInstanceOf(DimTypes.scalar);
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(
|
||||
crossfilter.dimensions[layoutDimensionName("XY")].dim
|
||||
).toBeInstanceOf(DimTypes.spatial);
|
||||
});
|
||||
});
|
||||
|
||||
describe("worldEqUniverse", () => {
|
||||
const { universe, world } = defaultBigBang();
|
||||
const result = World.worldEqUniverse(world, universe);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
@@ -253,6 +253,11 @@ describe("ImmutableTypedCrossfilter", () => {
|
||||
p.select("quantity", { mode: "exact", values: v }).countSelected()
|
||||
).toEqual(_.filter(someData, (d) => v.includes(d.quantity)).length)
|
||||
);
|
||||
test("single value exact", () => {
|
||||
expect(
|
||||
p.select("quantity", { mode: "exact", values: 2 }).countSelected()
|
||||
).toEqual(_.filter(someData, (d) => d.quantity === 2).length);
|
||||
});
|
||||
test.each([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
@@ -295,6 +300,11 @@ describe("ImmutableTypedCrossfilter", () => {
|
||||
p.select("type", { mode: "exact", values: v }).countSelected()
|
||||
).toEqual(_.filter(someData, (d) => v.includes(d.type)).length)
|
||||
);
|
||||
test("single value exact", () => {
|
||||
expect(
|
||||
p.select("type", { mode: "exact", values: "tab" }).countSelected()
|
||||
).toEqual(_.filter(someData, (d) => d.type === "tab").length);
|
||||
});
|
||||
test("range", () => {
|
||||
expect(() => p.select("type", { mode: "range", lo: 0, hi: 9 })).toThrow(
|
||||
Error
|
||||
|
||||
Generated
+492
-828
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -44,6 +44,7 @@
|
||||
"lodash": "^4.17.15",
|
||||
"memoize-one": "^5.1.1",
|
||||
"react": "^16.13.1",
|
||||
"react-async": "^10.0.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-flip-toolkit": "7.0.6",
|
||||
"react-helmet": "^5.2.1",
|
||||
@@ -51,7 +52,8 @@
|
||||
"react-redux": "^7.2.0",
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"regl": "^1.6.1"
|
||||
"regl": "^1.6.1",
|
||||
"tinyqueue": "^2.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.10.2",
|
||||
@@ -102,6 +104,7 @@
|
||||
"jest": "^26.0.1",
|
||||
"jest-circus": "^26.0.1",
|
||||
"jest-environment-puppeteer": "^4.4.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jest-puppeteer": "^4.4.0",
|
||||
"json-loader": "^0.5.7",
|
||||
"lint-staged": "^10.2.9",
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
/*
|
||||
Action creators for user annotation
|
||||
*/
|
||||
import _ from "lodash";
|
||||
import * as globals from "../globals";
|
||||
import { MatrixFBS, AnnotationsHelpers } from "../util/stateManager";
|
||||
|
||||
const { isUserAnnotation } = AnnotationsHelpers;
|
||||
|
||||
export const annotationCreateCategoryAction = (
|
||||
newCategoryName,
|
||||
categoryToDuplicate
|
||||
) => async (dispatch, getState) => {
|
||||
/*
|
||||
Add a new user-created category to the obs annotations.
|
||||
|
||||
Arguments:
|
||||
newCategoryName - string name for the category.
|
||||
categoryToDuplicate - obs category to use for initial values, or null.
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
const { schema } = prevAnnoMatrix;
|
||||
|
||||
/* name must be a string, non-zero length */
|
||||
if (typeof newCategoryName !== "string" || newCategoryName.length === 0)
|
||||
throw new Error("user annotations require string name");
|
||||
|
||||
/* ensure the name isn't already in use! */
|
||||
if (schema.annotations.obsByName[newCategoryName])
|
||||
throw new Error("name collision on annotation category create");
|
||||
|
||||
let initialValue;
|
||||
let categories;
|
||||
if (categoryToDuplicate) {
|
||||
/* if we are duplicating a category, retrieve it */
|
||||
const catDupSchema = schema.annotations.obsByName[categoryToDuplicate];
|
||||
const catDupType = catDupSchema?.type;
|
||||
if (catDupType !== "string" && catDupType !== "categorical")
|
||||
throw new Error("categoryToDuplicate does not exist or has invalid type");
|
||||
|
||||
const catToDupDf = await prevAnnoMatrix
|
||||
.base()
|
||||
.fetch("obs", categoryToDuplicate);
|
||||
const col = catToDupDf.col(categoryToDuplicate);
|
||||
initialValue = col.asArray();
|
||||
({ categories } = col.summarize());
|
||||
} else {
|
||||
/* else assign to the standard default value */
|
||||
initialValue = globals.unassignedCategoryLabel;
|
||||
categories = [globals.unassignedCategoryLabel];
|
||||
}
|
||||
|
||||
const obsCrossfilter = prevObsCrossfilter.addObsColumn(
|
||||
{
|
||||
name: newCategoryName,
|
||||
type: "categorical",
|
||||
categories,
|
||||
writable: true,
|
||||
},
|
||||
Array,
|
||||
initialValue
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: create category",
|
||||
data: newCategoryName,
|
||||
categoryToDuplicate,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationRenameCategoryAction = (
|
||||
oldCategoryName,
|
||||
newCategoryName
|
||||
) => (dispatch, getState) => {
|
||||
/*
|
||||
Rename a user-created annotation category
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, oldCategoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
/* name must be a string, non-zero length */
|
||||
if (typeof newCategoryName !== "string" || newCategoryName.length === 0)
|
||||
throw new Error("user annotations require string name");
|
||||
|
||||
if (oldCategoryName === newCategoryName) return;
|
||||
|
||||
const obsCrossfilter = prevObsCrossfilter.renameObsColumn(
|
||||
oldCategoryName,
|
||||
newCategoryName
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: category edited",
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
metadataField: oldCategoryName,
|
||||
newCategoryText: newCategoryName,
|
||||
data: newCategoryName,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationDeleteCategoryAction = (categoryName) => (
|
||||
dispatch,
|
||||
getState
|
||||
) => {
|
||||
/*
|
||||
Delete a user-created category
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
const obsCrossfilter = prevObsCrossfilter.dropObsColumn(categoryName);
|
||||
dispatch({
|
||||
type: "annotation: delete category",
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
metadataField: categoryName,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationCreateLabelInCategory = (
|
||||
categoryName,
|
||||
labelName,
|
||||
assignSelected
|
||||
) => async (dispatch, getState) => {
|
||||
/*
|
||||
Add a new label to a user-defined category. If assignSelected is true, assign
|
||||
the label to all currently selected cells.
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
let obsCrossfilter = prevObsCrossfilter.addObsAnnoCategory(
|
||||
categoryName,
|
||||
labelName
|
||||
);
|
||||
if (assignSelected) {
|
||||
obsCrossfilter = await obsCrossfilter.setObsColumnValues(
|
||||
categoryName,
|
||||
prevObsCrossfilter.allSelectedLabels(),
|
||||
labelName
|
||||
);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "annotation: add new label to category",
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
metadataField: categoryName,
|
||||
newLabelText: labelName,
|
||||
assignSelectedCells: assignSelected,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationDeleteLabelFromCategory = (
|
||||
categoryName,
|
||||
labelName
|
||||
) => async (dispatch, getState) => {
|
||||
/*
|
||||
delete a label from a user-defined category
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
const obsCrossfilter = await prevObsCrossfilter.removeObsAnnoCategory(
|
||||
categoryName,
|
||||
labelName,
|
||||
globals.unassignedCategoryLabel
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: delete label",
|
||||
metadataField: categoryName,
|
||||
label: labelName,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationRenameLabelInCategory = (
|
||||
categoryName,
|
||||
oldLabelName,
|
||||
newLabelName
|
||||
) => async (dispatch, getState) => {
|
||||
/*
|
||||
label name change
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
let obsCrossfilter = await prevObsCrossfilter.resetObsColumnValues(
|
||||
categoryName,
|
||||
oldLabelName,
|
||||
newLabelName
|
||||
);
|
||||
obsCrossfilter = await obsCrossfilter.removeObsAnnoCategory(
|
||||
categoryName,
|
||||
oldLabelName,
|
||||
globals.unassignedCategoryLabel
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: label edited",
|
||||
editedLabel: newLabelName,
|
||||
metadataField: categoryName,
|
||||
label: oldLabelName,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationLabelCurrentSelection = (
|
||||
categoryName,
|
||||
labelName
|
||||
) => async (dispatch, getState) => {
|
||||
/*
|
||||
set the label on all currently selected
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
const obsCrossfilter = await prevObsCrossfilter.setObsColumnValues(
|
||||
categoryName,
|
||||
prevObsCrossfilter.allSelectedLabels(),
|
||||
labelName
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: label current cell selection",
|
||||
metadataField: categoryName,
|
||||
label: labelName,
|
||||
obsCrossfilter,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
});
|
||||
};
|
||||
|
||||
function writableAnnotations(annoMatrix) {
|
||||
return annoMatrix.schema.annotations.obs.columns
|
||||
.filter((s) => s.writable)
|
||||
.map((s) => s.name);
|
||||
}
|
||||
|
||||
export const needToSaveObsAnnotations = (annoMatrix, lastSavedAnnoMatrix) => {
|
||||
/*
|
||||
Return true if there are LIKELY user-defined annotation modifications between the two
|
||||
annoMatrices. Technically not an action creator, but intimately intertwined
|
||||
with the save process.
|
||||
|
||||
Two conditions will trigger a need to save:
|
||||
* the collection of user-defined columns have changed
|
||||
* the contents of the user-defined columns have change
|
||||
*/
|
||||
|
||||
annoMatrix = annoMatrix.base();
|
||||
|
||||
// if the annoMatrix hasn't changed, we are guaranteed no changes to the matrix schema or contents.
|
||||
if (annoMatrix === lastSavedAnnoMatrix) return false;
|
||||
|
||||
// if the schema has changed, we need to save
|
||||
const currentWritable = writableAnnotations(annoMatrix);
|
||||
if (_.difference(currentWritable, writableAnnotations(lastSavedAnnoMatrix))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// no schema changes; check for change in contents
|
||||
return currentWritable.some(
|
||||
(col) => annoMatrix.col(col) !== lastSavedAnnoMatrix.col(col)
|
||||
);
|
||||
};
|
||||
|
||||
export const saveObsAnnotationsAction = () => async (dispatch, getState) => {
|
||||
/*
|
||||
Save the user-created obs annotations IF any have changed.
|
||||
*/
|
||||
const state = getState();
|
||||
const { annotations, autosave } = state;
|
||||
const { dataCollectionNameIsReadOnly, dataCollectionName } = annotations;
|
||||
const { lastSavedAnnoMatrix, saveInProgress } = autosave;
|
||||
|
||||
const annoMatrix = state.annoMatrix.base();
|
||||
|
||||
if (saveInProgress || annoMatrix === lastSavedAnnoMatrix) return;
|
||||
if (!needToSaveObsAnnotations(annoMatrix, lastSavedAnnoMatrix)) {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save complete",
|
||||
lastSavedAnnoMatrix: annoMatrix,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
Else, we really do need to save
|
||||
*/
|
||||
|
||||
dispatch({
|
||||
type: "writable obs annotations - save started",
|
||||
});
|
||||
|
||||
const df = await annoMatrix.fetch("obs", writableAnnotations(annoMatrix));
|
||||
const matrix = MatrixFBS.encodeMatrixFBS(df);
|
||||
try {
|
||||
const queryString =
|
||||
!dataCollectionNameIsReadOnly && !!dataCollectionName
|
||||
? `?annotation-collection-name=${encodeURIComponent(
|
||||
dataCollectionName
|
||||
)}`
|
||||
: "";
|
||||
const res = await fetch(
|
||||
`${globals.API.prefix}${globals.API.version}annotations/obs${queryString}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: matrix,
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/octet-stream",
|
||||
}),
|
||||
credentials: "include",
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save complete",
|
||||
lastSavedAnnoMatrix: annoMatrix,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save error",
|
||||
message: `HTTP error ${res.status} - ${res.statusText}`,
|
||||
res,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save error",
|
||||
message: error.toString(),
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
+139
-313
@@ -1,93 +1,21 @@
|
||||
import * as globals from "../globals";
|
||||
import { Universe, MatrixFBS } from "../util/stateManager";
|
||||
import * as Dataframe from "../util/dataframe";
|
||||
import {
|
||||
AnnoMatrixLoader,
|
||||
AnnoMatrixObsCrossfilter,
|
||||
clip,
|
||||
isubsetMask,
|
||||
} from "../annoMatrix";
|
||||
import {
|
||||
catchErrorsWrap,
|
||||
doJsonRequest,
|
||||
doBinaryRequest,
|
||||
dispatchNetworkErrorMessageToUser,
|
||||
} from "../util/actionHelpers";
|
||||
import PromiseLimit from "../util/promiseLimit";
|
||||
import { requestReembed, reembedResetWorldToUniverse } from "./reembed";
|
||||
import {
|
||||
requestReembed /* , reembedResetWorldToUniverse -- disabled temporarily, TODO issue #1606 */,
|
||||
} from "./reembed";
|
||||
import { loadUserColorConfig } from "../util/stateManager/colorHelpers";
|
||||
|
||||
/*
|
||||
return promise to fetch the OBS annotations we need to load. Omit anything
|
||||
we don't need.
|
||||
*/
|
||||
async function obsAnnotationFetchAndLoad(dispatch, schema) {
|
||||
const obsAnnotations = schema?.schema?.annotations?.obs ?? {};
|
||||
const index = obsAnnotations.index ?? false;
|
||||
const columns = (obsAnnotations.columns ?? []).filter(
|
||||
(col) => col.name !== index
|
||||
);
|
||||
|
||||
const plimit = new PromiseLimit(5);
|
||||
return Promise.all(
|
||||
columns.map((col) =>
|
||||
plimit.add(() =>
|
||||
fetchBinary(
|
||||
`annotations/obs?annotation-name=${encodeURIComponent(col.name)}`
|
||||
)
|
||||
.then((buffer) => MatrixFBS.matrixFBSToDataframe(buffer))
|
||||
.then((df) =>
|
||||
dispatch({
|
||||
type: "universe: column load success",
|
||||
dim: "obsAnnotations",
|
||||
dataframe: df,
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
return promise fetching VAR annotations we need to load. Only index is currently used.
|
||||
*/
|
||||
async function varAnnotationFetchAndLoad(dispatch, schema) {
|
||||
const varAnnotations = schema?.schema?.annotations?.var ?? {};
|
||||
const index = varAnnotations.index ?? false;
|
||||
const names = index ? [index] : [];
|
||||
return Promise.all(
|
||||
names.map((name) =>
|
||||
fetchBinary(`annotations/var?annotation-name=${encodeURIComponent(name)}`)
|
||||
.then((buffer) => MatrixFBS.matrixFBSToDataframe(buffer))
|
||||
.then((df) =>
|
||||
dispatch({
|
||||
type: "universe: column load success",
|
||||
dim: "varAnnotations",
|
||||
dataframe: df,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
return promise fetching layout we need
|
||||
*/
|
||||
function layoutFetchAndLoad(dispatch, schema) {
|
||||
const embeddings = schema?.schema?.layout?.obs ?? [];
|
||||
const embNames = embeddings.map((e) => e.name);
|
||||
|
||||
const plimit = new PromiseLimit(5);
|
||||
return Promise.all(
|
||||
embNames.map((e) =>
|
||||
plimit.add(() =>
|
||||
fetchBinary(
|
||||
`layout/obs?layout-name=${encodeURIComponent(e)}`
|
||||
).then((buffer) => MatrixFBS.matrixFBSToDataframe(buffer))
|
||||
)
|
||||
)
|
||||
).then((dfs) =>
|
||||
dispatch({
|
||||
type: "universe: column load success",
|
||||
dim: "obsLayout",
|
||||
dataframe: Dataframe.Dataframe.empty().withColsFromAll(dfs),
|
||||
})
|
||||
);
|
||||
}
|
||||
import * as selnActions from "./selection";
|
||||
import * as annoActions from "./annotation";
|
||||
|
||||
/*
|
||||
return promise fetching user-configured colors
|
||||
@@ -101,178 +29,62 @@ async function userColorsFetchAndLoad(dispatch) {
|
||||
);
|
||||
}
|
||||
|
||||
async function schemaFetch() {
|
||||
return fetchJson("schema");
|
||||
}
|
||||
|
||||
async function configFetch(dispatch) {
|
||||
return fetchJson("config").then((response) => {
|
||||
const config = { ...globals.configDefaults, ...response.config };
|
||||
dispatch({
|
||||
type: "configuration load complete",
|
||||
config,
|
||||
});
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
Bootstrap application with the initial data loading.
|
||||
* /config - application configuration
|
||||
* /schema - schema of dataframe
|
||||
* /annotations - all metadata annotation
|
||||
* /layout - all default layout
|
||||
Application bootstrap
|
||||
*/
|
||||
const doInitialDataLoad = () =>
|
||||
catchErrorsWrap(async (dispatch) => {
|
||||
dispatch({ type: "initial data load start" });
|
||||
|
||||
try {
|
||||
/*
|
||||
Step 1 - config & schema, all JSON
|
||||
*/
|
||||
const requestJson = ["config", "schema"].map(fetchJson);
|
||||
const [responseConfig, schema] = await Promise.all(requestJson);
|
||||
/* set config defaults */
|
||||
const config = { ...globals.configDefaults, ...responseConfig.config };
|
||||
const universe = Universe.createUniverseFromResponse(config, schema);
|
||||
dispatch({
|
||||
type: "universe exists, but loading is still in progress",
|
||||
universe,
|
||||
});
|
||||
dispatch({
|
||||
type: "configuration load complete",
|
||||
config,
|
||||
});
|
||||
|
||||
/*
|
||||
Step 2 - load the minimum stuff required to display.
|
||||
*/
|
||||
await Promise.all([
|
||||
const [, schema] = await Promise.all([
|
||||
configFetch(dispatch),
|
||||
schemaFetch(dispatch),
|
||||
userColorsFetchAndLoad(dispatch),
|
||||
layoutFetchAndLoad(dispatch, schema),
|
||||
varAnnotationFetchAndLoad(dispatch, schema),
|
||||
]);
|
||||
|
||||
/*
|
||||
Step 3 - load everything else
|
||||
*/
|
||||
await obsAnnotationFetchAndLoad(dispatch, schema);
|
||||
|
||||
const baseDataUrl = `${globals.API.prefix}${globals.API.version}`;
|
||||
const annoMatrix = new AnnoMatrixLoader(baseDataUrl, schema.schema);
|
||||
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||
dispatch({
|
||||
type: "initial data load complete (universe exists)",
|
||||
universe,
|
||||
type: "annoMatrix: init complete",
|
||||
annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
dispatch({ type: "initial data load complete" });
|
||||
} catch (error) {
|
||||
dispatch({ type: "initial data load error", error });
|
||||
}
|
||||
}, true);
|
||||
|
||||
/*
|
||||
Set the view (world) to current selection. Placeholder for an async action
|
||||
which also does re-layout.
|
||||
*/
|
||||
const setWorldToSelection = () => (dispatch, getState) => {
|
||||
const { universe, world, crossfilter } = getState();
|
||||
dispatch({
|
||||
type: "set World to current selection",
|
||||
universe,
|
||||
world,
|
||||
crossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
/* double URI encode - needed for query-param filters */
|
||||
function dubEncURIComponent(s) {
|
||||
return encodeURIComponent(encodeURIComponent(s));
|
||||
}
|
||||
|
||||
/*
|
||||
Fetch expression vectors for each gene in genes. This is NOT an action
|
||||
function, but rather a helper to be called from an action helper that
|
||||
needs expression data.
|
||||
|
||||
Transparently utilizes cached data if it is already present.
|
||||
*/
|
||||
async function _doRequestExpressionData(dispatch, getState, genes) {
|
||||
const state = getState();
|
||||
const { universe } = state;
|
||||
const varIndexName = universe.schema.annotations.var.index;
|
||||
|
||||
/* helper for this function only */
|
||||
const fetchData = async (geneNames) => {
|
||||
const query = geneNames
|
||||
.map(
|
||||
(g) =>
|
||||
`var:${dubEncURIComponent(varIndexName)}=${dubEncURIComponent(g)}`
|
||||
)
|
||||
.join("&");
|
||||
// TODO: why convert to an Object and not a Dataframe?
|
||||
return fetchBinary(`data/var?${query}`).then((buffer) =>
|
||||
Universe.convertDataFBStoObject(universe, buffer)
|
||||
);
|
||||
};
|
||||
|
||||
/* preload data already in cache */
|
||||
let expressionData = genes.reduce((acc, g) => {
|
||||
const data = universe.varData.col(g);
|
||||
if (data) {
|
||||
acc[g] = data.asArray();
|
||||
}
|
||||
return acc;
|
||||
}, {}); // --> { gene: data }
|
||||
|
||||
/* make a list of genes for which we do not have data */
|
||||
const genesToFetch = genes.filter((g) => expressionData[g] === undefined);
|
||||
|
||||
dispatch({ type: "expression load start" });
|
||||
|
||||
/* Fetch data for any genes not in cache */
|
||||
if (genesToFetch.length) {
|
||||
try {
|
||||
const newExpressionData = await fetchData(genesToFetch);
|
||||
expressionData = {
|
||||
...expressionData,
|
||||
...newExpressionData,
|
||||
};
|
||||
} catch (error) {
|
||||
dispatch({ type: "expression load error", error });
|
||||
throw error; // rethrow
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({ type: "expression load success", expressionData });
|
||||
return expressionData;
|
||||
}
|
||||
|
||||
function requestSingleGeneExpressionCountsForColoringPOST(gene) {
|
||||
return async (dispatch, getState) => {
|
||||
dispatch({ type: "get single gene expression for coloring started" });
|
||||
try {
|
||||
await _doRequestExpressionData(dispatch, getState, [gene]);
|
||||
const { world } = getState();
|
||||
dispatch({
|
||||
type: "color by expression",
|
||||
gene,
|
||||
data: {
|
||||
[gene]: world.varData.col(gene).asArray(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: "get single gene expression for coloring error",
|
||||
error,
|
||||
});
|
||||
}
|
||||
return {
|
||||
type: "color by expression",
|
||||
gene,
|
||||
};
|
||||
}
|
||||
|
||||
const requestUserDefinedGene = (gene) => async (dispatch, getState) => {
|
||||
dispatch({ type: "request user defined gene started" });
|
||||
try {
|
||||
await await _doRequestExpressionData(dispatch, getState, [gene]);
|
||||
const { world } = getState();
|
||||
|
||||
/* then send the success case action through */
|
||||
return dispatch({
|
||||
type: "request user defined gene success",
|
||||
data: {
|
||||
genes: [gene],
|
||||
expression: world.varData.col(gene).asArray(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return dispatch({
|
||||
type: "request user defined gene error",
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
const requestUserDefinedGene = (gene) => ({
|
||||
type: "request user defined gene success",
|
||||
data: {
|
||||
genes: [gene],
|
||||
},
|
||||
});
|
||||
|
||||
const dispatchDiffExpErrors = (dispatch, response) => {
|
||||
switch (response.status) {
|
||||
@@ -308,9 +120,8 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
||||
1. get the most differentially expressed genes
|
||||
2. get expression data for each
|
||||
*/
|
||||
const state = getState();
|
||||
const { universe } = state;
|
||||
const varIndexName = universe.schema.annotations.var.index;
|
||||
const { annoMatrix } = getState();
|
||||
const varIndexName = annoMatrix.schema.annotations.var.index;
|
||||
|
||||
// Legal values are null, Array or TypedArray. Null is initial state.
|
||||
if (!set1) set1 = [];
|
||||
@@ -345,22 +156,12 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
||||
return dispatchDiffExpErrors(dispatch, res);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
// result is [ [varIdx, ...], ... ]
|
||||
const topNGenes = data.map((r) =>
|
||||
universe.varAnnotations.at(r[0], varIndexName)
|
||||
);
|
||||
|
||||
/*
|
||||
Kick off secondary action to fetch all of the expression data for the
|
||||
topN expressed genes.
|
||||
*/
|
||||
const plimit = new PromiseLimit(5);
|
||||
await Promise.all(
|
||||
topNGenes.map((gene) =>
|
||||
plimit.add(() => _doRequestExpressionData(dispatch, getState, [gene]))
|
||||
)
|
||||
);
|
||||
const response = await res.json();
|
||||
const varIndex = await annoMatrix.fetch("var", varIndexName);
|
||||
const data = response.map((v) => [
|
||||
varIndex.at(v[0], varIndexName),
|
||||
...v.slice(1),
|
||||
]);
|
||||
|
||||
/* then send the success case action through */
|
||||
return dispatch({
|
||||
@@ -375,66 +176,73 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
||||
}
|
||||
};
|
||||
|
||||
const resetWorldToUniverse = () => (dispatch, getState) => {
|
||||
const { universe } = getState();
|
||||
reembedResetWorldToUniverse(dispatch, getState);
|
||||
const clipAction = (min, max) => (dispatch, getState) => {
|
||||
/*
|
||||
apply a clip to the current annoMatrix. By convention, the clip
|
||||
view is ALWAYS the top view.
|
||||
*/
|
||||
const { annoMatrix: prevAnnoMatrix } = getState();
|
||||
const annoMatrix = prevAnnoMatrix.isClipped
|
||||
? clip(prevAnnoMatrix.viewOf, min, max)
|
||||
: clip(prevAnnoMatrix, min, max);
|
||||
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||
dispatch({
|
||||
type: "reset World to eq Universe",
|
||||
universe,
|
||||
type: "set clip quantiles",
|
||||
clipQuantiles: { min, max },
|
||||
annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
const saveObsAnnotations = () => async (dispatch, getState) => {
|
||||
const { universe, annotations } = getState();
|
||||
const { obsAnnotations, schema } = universe;
|
||||
const { dataCollectionNameIsReadOnly, dataCollectionName } = annotations;
|
||||
const subsetAction = () => (dispatch, getState) => {
|
||||
/*
|
||||
Subset the annoMatrix to the current crossfilter selection
|
||||
*/
|
||||
const {
|
||||
annoMatrix: prevAnnoMatrix,
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
} = getState();
|
||||
|
||||
const annoMatrix = isubsetMask(
|
||||
prevAnnoMatrix,
|
||||
prevObsCrossfilter.allSelectedMask()
|
||||
);
|
||||
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||
dispatch({
|
||||
type: "writable obs annotations - save started",
|
||||
type: "subset to selection",
|
||||
annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
const writableAnnotations = schema.annotations.obs.columns
|
||||
.filter((s) => s.writable)
|
||||
.map((s) => s.name);
|
||||
const df = obsAnnotations.subset(null, writableAnnotations);
|
||||
const matrix = MatrixFBS.encodeMatrixFBS(df);
|
||||
try {
|
||||
const queryString =
|
||||
!dataCollectionNameIsReadOnly && !!dataCollectionName
|
||||
? `?annotation-collection-name=${encodeURIComponent(
|
||||
dataCollectionName
|
||||
)}`
|
||||
: "";
|
||||
const res = await fetch(
|
||||
`${globals.API.prefix}${globals.API.version}annotations/obs${queryString}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: matrix,
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/octet-stream",
|
||||
}),
|
||||
credentials: "include",
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save complete",
|
||||
obsAnnotations,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save error",
|
||||
message: `HTTP error ${res.status} - ${res.statusText}`,
|
||||
res,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save error",
|
||||
message: error.toString(),
|
||||
error,
|
||||
});
|
||||
const resetSubsetAction = () => (dispatch, getState) => {
|
||||
/*
|
||||
Reset the annoMatrix to all data. Because we may have multiple views
|
||||
stacked, we pop them all. By convention, any clip transformation will
|
||||
be the top of the stack, and must be preserved.
|
||||
*/
|
||||
|
||||
const { annoMatrix: prevAnnoMatrix } = getState();
|
||||
|
||||
const clipRange = prevAnnoMatrix.isClipped ? prevAnnoMatrix.clipRange : null;
|
||||
|
||||
/* pop all views */
|
||||
let annoMatrix = prevAnnoMatrix;
|
||||
while (annoMatrix.isView) {
|
||||
annoMatrix = annoMatrix.viewOf;
|
||||
}
|
||||
|
||||
/* re-apply the clip, if any */
|
||||
if (clipRange !== null) {
|
||||
annoMatrix = clip(annoMatrix, ...clipRange);
|
||||
}
|
||||
|
||||
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||
dispatch({
|
||||
type: "reset subset",
|
||||
annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
function fetchJson(pathAndQuery) {
|
||||
@@ -443,19 +251,37 @@ function fetchJson(pathAndQuery) {
|
||||
);
|
||||
}
|
||||
|
||||
function fetchBinary(pathAndQuery) {
|
||||
return doBinaryRequest(
|
||||
`${globals.API.prefix}${globals.API.version}${pathAndQuery}`
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
doInitialDataLoad,
|
||||
requestDifferentialExpression,
|
||||
requestSingleGeneExpressionCountsForColoringPOST,
|
||||
requestUserDefinedGene,
|
||||
requestReembed,
|
||||
resetWorldToUniverse,
|
||||
saveObsAnnotations,
|
||||
setWorldToSelection,
|
||||
selectContinuousMetadataAction: selnActions.selectContinuousMetadataAction,
|
||||
selectCategoricalMetadataAction: selnActions.selectCategoricalMetadataAction,
|
||||
selectCategoricalAllMetadataAction:
|
||||
selnActions.selectCategoricalAllMetadataAction,
|
||||
graphBrushStartAction: selnActions.graphBrushStartAction,
|
||||
graphBrushChangeAction: selnActions.graphBrushChangeAction,
|
||||
graphBrushDeselectAction: selnActions.graphBrushDeselectAction,
|
||||
graphBrushCancelAction: selnActions.graphBrushCancelAction,
|
||||
graphBrushEndAction: selnActions.graphBrushEndAction,
|
||||
graphLassoStartAction: selnActions.graphLassoStartAction,
|
||||
graphLassoEndAction: selnActions.graphLassoEndAction,
|
||||
graphLassoCancelAction: selnActions.graphLassoCancelAction,
|
||||
graphLassoDeselectAction: selnActions.graphLassoDeselectAction,
|
||||
clipAction,
|
||||
subsetAction,
|
||||
resetSubsetAction,
|
||||
annotationCreateCategoryAction: annoActions.annotationCreateCategoryAction,
|
||||
annotationRenameCategoryAction: annoActions.annotationRenameCategoryAction,
|
||||
annotationDeleteCategoryAction: annoActions.annotationDeleteCategoryAction,
|
||||
annotationCreateLabelInCategory: annoActions.annotationCreateLabelInCategory,
|
||||
annotationDeleteLabelFromCategory:
|
||||
annoActions.annotationDeleteLabelFromCategory,
|
||||
annotationRenameLabelInCategory: annoActions.annotationRenameLabelInCategory,
|
||||
annotationLabelCurrentSelection: annoActions.annotationLabelCurrentSelection,
|
||||
saveObsAnnotationsAction: annoActions.saveObsAnnotationsAction,
|
||||
needToSaveObsAnnotations: annoActions.needToSaveObsAnnotations,
|
||||
layoutChoiceAction: selnActions.layoutChoiceAction,
|
||||
};
|
||||
|
||||
@@ -104,6 +104,7 @@ export function requestReembed() {
|
||||
};
|
||||
}
|
||||
|
||||
/* disabled until reimplementation occurs
|
||||
export function reembedResetWorldToUniverse(dispatch, getState) {
|
||||
const { reembedController } = getState();
|
||||
if (reembedController.pendingFetch) reembedController.pendingFetch.abort();
|
||||
@@ -111,3 +112,4 @@ export function reembedResetWorldToUniverse(dispatch, getState) {
|
||||
type: "reembed: clear all reembeddings",
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
Action creators for selection
|
||||
*/
|
||||
export const selectContinuousMetadataAction = (
|
||||
type,
|
||||
query,
|
||||
range,
|
||||
oldProps = {}
|
||||
) => async (dispatch, getState) => {
|
||||
const { obsCrossfilter: prevObsCrossfilter } = getState();
|
||||
|
||||
const selection = range
|
||||
? {
|
||||
mode: "range",
|
||||
lo: range[0],
|
||||
hi: range[1],
|
||||
inclusive: true, // [lo, hi] incluisve selection
|
||||
}
|
||||
: { mode: "all" };
|
||||
|
||||
const obsCrossfilter = await prevObsCrossfilter.select(...query, selection);
|
||||
|
||||
dispatch({
|
||||
type,
|
||||
obsCrossfilter,
|
||||
range,
|
||||
...oldProps,
|
||||
});
|
||||
};
|
||||
|
||||
export const selectCategoricalMetadataAction = (
|
||||
type, // action type
|
||||
metadataField, // annotation category name
|
||||
labels,
|
||||
label, // the label being selected/deselected
|
||||
isSelected, // bool
|
||||
oldProps = {}
|
||||
) => async (dispatch, getState) => {
|
||||
const {
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
categoricalSelection,
|
||||
} = getState();
|
||||
|
||||
const labelSelectionState = new Map(categoricalSelection[metadataField]);
|
||||
labels.forEach(
|
||||
(l) => labelSelectionState.has(l) || labelSelectionState.set(l, true)
|
||||
);
|
||||
labelSelectionState.set(label, isSelected);
|
||||
|
||||
const values = Array.from(labelSelectionState.keys()).filter((k) =>
|
||||
labelSelectionState.get(k)
|
||||
);
|
||||
const selection = {
|
||||
mode: "exact",
|
||||
values,
|
||||
};
|
||||
const obsCrossfilter = await prevObsCrossfilter.select(
|
||||
"obs",
|
||||
metadataField,
|
||||
selection
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type,
|
||||
obsCrossfilter,
|
||||
metadataField,
|
||||
labelSelectionState,
|
||||
...oldProps,
|
||||
});
|
||||
};
|
||||
|
||||
export const selectCategoricalAllMetadataAction = (
|
||||
type, // action type
|
||||
metadataField, // annotation category name
|
||||
labels,
|
||||
isSelected, // bool, select all or none
|
||||
oldProps = {}
|
||||
) => async (dispatch, getState) => {
|
||||
const {
|
||||
obsCrossfilter: prevObsCrossfilter,
|
||||
categoricalSelection,
|
||||
} = getState();
|
||||
|
||||
const labelSelectionState = new Map(categoricalSelection[metadataField]);
|
||||
labels.forEach((label) => labelSelectionState.set(label, isSelected));
|
||||
|
||||
const selection = { mode: isSelected ? "all" : "none" };
|
||||
const obsCrossfilter = await prevObsCrossfilter.select(
|
||||
"obs",
|
||||
metadataField,
|
||||
selection
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type,
|
||||
obsCrossfilter,
|
||||
metadataField,
|
||||
labelSelectionState,
|
||||
...oldProps,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
** Graph selection-related actions
|
||||
**/
|
||||
|
||||
export const graphBrushStartAction = () =>
|
||||
/* no change to crossfilter until a change fires */
|
||||
({ type: "graph brush start" });
|
||||
|
||||
const _graphBrushWithinRectAction = (type, embName, brushCoords) => async (
|
||||
dispatch,
|
||||
getState
|
||||
) => {
|
||||
const { obsCrossfilter: prevObsCrossfilter } = getState();
|
||||
|
||||
const selection = { mode: "within-rect", ...brushCoords };
|
||||
const obsCrossfilter = await prevObsCrossfilter.select(
|
||||
"emb",
|
||||
embName,
|
||||
selection
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type,
|
||||
obsCrossfilter,
|
||||
brushCoords,
|
||||
});
|
||||
};
|
||||
|
||||
const _graphAllAction = (type, embName) => async (dispatch, getState) => {
|
||||
const { obsCrossfilter: prevObsCrossfilter } = getState();
|
||||
|
||||
const obsCrossfilter = await prevObsCrossfilter.select("emb", embName, {
|
||||
mode: "all",
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
export const graphBrushChangeAction = (embName, brushCoords) =>
|
||||
_graphBrushWithinRectAction("graph brush change", embName, brushCoords);
|
||||
|
||||
export const graphBrushEndAction = (embName, brushCoords) =>
|
||||
_graphBrushWithinRectAction("graph brush end", embName, brushCoords);
|
||||
|
||||
export const graphBrushCancelAction = (embName) =>
|
||||
_graphAllAction("graph brush cancel", embName);
|
||||
export const graphBrushDeselectAction = (embName) =>
|
||||
_graphAllAction("graph brush deselect", embName);
|
||||
|
||||
export const graphLassoStartAction = () =>
|
||||
/* no change to crossfilter until a change fires */
|
||||
({ type: "graph lasso start" });
|
||||
|
||||
export const graphLassoCancelAction = (embName) =>
|
||||
_graphAllAction("graph lasso cancel", embName);
|
||||
|
||||
export const graphLassoDeselectAction = (embName) =>
|
||||
_graphAllAction("graph lasso cancel", embName);
|
||||
|
||||
export const graphLassoEndAction = (embName, polygon) => async (
|
||||
dispatch,
|
||||
getState
|
||||
) => {
|
||||
const { obsCrossfilter: prevObsCrossfilter } = getState();
|
||||
|
||||
const selection = {
|
||||
mode: "within-polygon",
|
||||
polygon,
|
||||
};
|
||||
const obsCrossfilter = await prevObsCrossfilter.select(
|
||||
"emb",
|
||||
embName,
|
||||
selection
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "graph lasso end",
|
||||
obsCrossfilter,
|
||||
polygon,
|
||||
});
|
||||
};
|
||||
|
||||
export const layoutChoiceAction = (newLayoutChoice) => async (
|
||||
dispatch,
|
||||
getState
|
||||
) => {
|
||||
/*
|
||||
On layout choice, make sure we have selected all on the previous layout, AND the new
|
||||
layout.
|
||||
*/
|
||||
const { obsCrossfilter: prevObsCrossfilter, layoutChoice } = getState();
|
||||
|
||||
let obsCrossfilter = await prevObsCrossfilter.select(
|
||||
"emb",
|
||||
layoutChoice.current,
|
||||
{ mode: "all" }
|
||||
);
|
||||
obsCrossfilter = await obsCrossfilter.select("emb", newLayoutChoice, {
|
||||
mode: "all",
|
||||
});
|
||||
dispatch({
|
||||
type: "set layout choice",
|
||||
layoutChoice: newLayoutChoice,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,634 @@
|
||||
import { Dataframe, IdentityInt32Index } from "../util/dataframe";
|
||||
import {
|
||||
_getColumnDimensionNames,
|
||||
_getColumnSchema,
|
||||
_schemaColumns,
|
||||
_getWritableColumns,
|
||||
} from "./schema";
|
||||
import { indexEntireSchema } from "../util/stateManager/schemaHelpers";
|
||||
import { _whereCacheGet, _whereCacheMerge } from "./whereCache";
|
||||
import _shallowClone from "./clone";
|
||||
|
||||
export default class AnnoMatrix {
|
||||
/*
|
||||
Abstract base class for all AnnoMatrix objects. This class provides a proxy
|
||||
to the annotated matrix data authoritatively served by the server/back-end.
|
||||
|
||||
AnnoMatrix instances are immutable, meaning that their schema and dimensionality
|
||||
will not change, and simple object equality can be used to detect structural
|
||||
changes. The actual data is cached, and not guaranteed to be present -- any
|
||||
request to access data must be resolved by a fetch() call, which is async, and
|
||||
may involve a server round-trip.
|
||||
|
||||
Guarantees made by the immutabilty, ie, any of these can be detected by
|
||||
simple annoMatrix compare:
|
||||
* schema is the same, including all fields and columns
|
||||
* dimensionality is the same (nObs, nVar)
|
||||
* data mapping/transformation, such as clipping, are the same
|
||||
|
||||
AnnoMatrixes also "stack" like filters, allowing for the construction of
|
||||
views which transform the data in some manner.
|
||||
|
||||
The bootstrap class is AnnoMatrixLoader, which is the caching server proxy, and
|
||||
is bootstrapped with a API URL:
|
||||
new AnnoMatirx(url, schema) -> annoMatrix
|
||||
|
||||
There are various "views", such as AnnoMatrixRowSubsetView, which provide
|
||||
the same interface but with a transformed view of the server data. Utilities in
|
||||
viewCreators.js can be used to create these views:
|
||||
clip(annoMatrix, min, max) -> annoMatrix
|
||||
subset(annoMatrix, rowLabels) -> annoMatrix
|
||||
etc.
|
||||
*/
|
||||
static fields() {
|
||||
/*
|
||||
return the fields present in the AnnoMatrix instance.
|
||||
*/
|
||||
return ["obs", "var", "emb", "X"];
|
||||
}
|
||||
|
||||
constructor(schema, nObs, nVar, rowIndex = null) {
|
||||
/*
|
||||
Private constructor - this is an abstract base class. Do not use.
|
||||
*/
|
||||
|
||||
/*
|
||||
Public instance fields:
|
||||
* schema - the matrix schema. IMPORTANT: always the entire schema, for the
|
||||
base (unfiltered, unclipped, unsubset) annotated matrix, as the server
|
||||
presents it.
|
||||
* nObs, nVar - size of each dimension. These will accurately reflect the
|
||||
size of the current annoMatrix view. For example, if you subset the view,
|
||||
the nObs will be smaller.
|
||||
* rowIndex - a rowIndex shared by all data on this view (ie, the list of cells).
|
||||
The row index labels are as defined by the base dataset from the server.
|
||||
* isView - true if this is a view, false if not.
|
||||
* viewOf - pointer to parent annomatrix if a view, undefined/null if not a view.
|
||||
*/
|
||||
this.schema = indexEntireSchema(schema);
|
||||
this.nObs = nObs;
|
||||
this.nVar = nVar;
|
||||
this.rowIndex = rowIndex || new IdentityInt32Index(nObs);
|
||||
this.isView = false;
|
||||
this.viewOf = undefined;
|
||||
|
||||
/*
|
||||
Private instance variables.
|
||||
|
||||
These are caches - lazily loaded. The only guarantee is that if they
|
||||
are loaded, they will conform to the schema & dimensionality constraints.
|
||||
|
||||
Do NOT use directly - instead, use the fetch() and preload() API.
|
||||
*/
|
||||
this._cache = {
|
||||
obs: Dataframe.empty(this.rowIndex),
|
||||
var: Dataframe.empty(this.rowIndex),
|
||||
emb: Dataframe.empty(this.rowIndex),
|
||||
X: Dataframe.empty(this.rowIndex),
|
||||
};
|
||||
this._pendingLoad = {
|
||||
obs: {},
|
||||
var: {},
|
||||
emb: {},
|
||||
X: {},
|
||||
};
|
||||
this._whereCache = {};
|
||||
this._gcInfo = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
** Schema helper/accessors
|
||||
**/
|
||||
getMatrixColumns(field) {
|
||||
/*
|
||||
Return array of column names in the field. ONLY supported on the
|
||||
obs, var and emb fields. X currently unimplemented and will throw.
|
||||
|
||||
For exmaple:
|
||||
|
||||
annoMatrix.getMatrixColumns("obs") -> ["louvain", "n_genes"]
|
||||
*/
|
||||
return _schemaColumns(this.schema, field);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this -- need to be able to call this on instances
|
||||
getMatrixFields() {
|
||||
/*
|
||||
Return array of fields in this annoMatrix. Currently hard-wired to
|
||||
return: ["X", "obs", "var", "emb"].
|
||||
|
||||
These are the fields from data may be requested.
|
||||
*/
|
||||
return AnnoMatrix.fields();
|
||||
}
|
||||
|
||||
getColumnSchema(field, col) {
|
||||
/*
|
||||
Return the schema for the field & column ,eg,
|
||||
|
||||
anonMatrix.getColumnSchema("obs", "n_genes") -> { type: "int32", name: "n_genes" }
|
||||
|
||||
This is identical to the information in the annoMatrix.schema
|
||||
instance variable.
|
||||
*/
|
||||
return _getColumnSchema(this.schema, field, col);
|
||||
}
|
||||
|
||||
getColumnDimensions(field, col) {
|
||||
/*
|
||||
Return the dimensions on this field / column. For most fields, which are 1D,
|
||||
this just return the column name. Multi-dimensional columns, such as embeddings,
|
||||
will return >1 name.
|
||||
|
||||
Examples:
|
||||
|
||||
getColumnDimensions("obs", "louvain") -> ["louvain"]
|
||||
getColumnDimensions("emb", "umap") -> ["umap_0", "umap_1"]
|
||||
|
||||
*/
|
||||
return _getColumnDimensionNames(this.schema, field, col);
|
||||
}
|
||||
|
||||
/**
|
||||
** General utility methods
|
||||
**/
|
||||
base() {
|
||||
/*
|
||||
return the base of view, or `this` if not a view.
|
||||
*/
|
||||
let annoMatrix = this;
|
||||
while (annoMatrix.isView) annoMatrix = annoMatrix.viewOf;
|
||||
return annoMatrix;
|
||||
}
|
||||
|
||||
/**
|
||||
** Load / read interfaces
|
||||
**/
|
||||
fetch(field, q) {
|
||||
/*
|
||||
Return the given query on a single matrix field as a single dataframe.
|
||||
Currently supports ONLY full column query.
|
||||
|
||||
Returns a Promise for the query result, which will resolve to a dataframe.
|
||||
|
||||
Field must be one of the matrix fields: 'obs', 'var', 'X', 'emb'. Value
|
||||
represents the underlying object upon which the query is occuring.
|
||||
|
||||
Query is one of:
|
||||
* a string, representing a single column name from the field, eg,
|
||||
"n_genes"
|
||||
* an object, containing an "value" query (see below).
|
||||
* an array, containing one or more of the above.
|
||||
|
||||
Columns may have more than one dimension, and all will be fetched
|
||||
and returned together. This is most commonly seen in an embedding,
|
||||
which usually has two dimensions.
|
||||
|
||||
A value query allows for fetching based upon the value in another
|
||||
field/column, similar to a join. Currently only supported on the var
|
||||
dimension, allowing query of X columns by var value (eg, gene name)
|
||||
|
||||
The query filter is a single value filter:
|
||||
{ "field name": [
|
||||
{name: "column name", values: [ list of values ]}
|
||||
]}
|
||||
One and only one value filter is allowed in a value query.
|
||||
|
||||
Examples:
|
||||
|
||||
1. Fetch the "n_genes" column the "obs":
|
||||
|
||||
const df = await fetch("obs", "n_genes")
|
||||
console.log("Largest number of genes is: ", df.summarize().max);
|
||||
|
||||
2. Fetch two separate columns from obs. Returns a single dataframe containing
|
||||
the columns:
|
||||
|
||||
const df = await fetch("obs", ["n_genes", "louvain"])
|
||||
console.log("Cell 0 has category: ", df.at(0, "louvain"));
|
||||
|
||||
3. Fetch an entire X (expression counts) column that has a var annotation
|
||||
value "TYMP" in the var index.
|
||||
|
||||
fetch("X", {
|
||||
where: {field: "var", column: this.schema.annotations.var.index, value: "TYMP"}
|
||||
})
|
||||
|
||||
In AnnData & Pandas DataFrame API, this is equivalent to:
|
||||
adata.X[:, adata.var.index.get_loc("SUMO3")]
|
||||
|
||||
The value query is a recodification and subset of the server REST API
|
||||
value filter JSON. Range queries and multiple filters are not currently
|
||||
supported.
|
||||
|
||||
*/
|
||||
return this._fetch(field, q);
|
||||
}
|
||||
|
||||
prefetch(field, q) {
|
||||
/*
|
||||
Start a data fetch & cache fill. Identical to fetch() except it does
|
||||
not return a value.
|
||||
|
||||
Primary use is to being a cache load as early as is possible, reducing
|
||||
overall component rendering latency.
|
||||
*/
|
||||
this._fetch(field, q);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
** Save / mutate interfaces - manipulation of "writable" OBS annotations.
|
||||
**
|
||||
** These are all present to support client-side creation of OBS annotations, aka
|
||||
** "user annotations".
|
||||
**
|
||||
** They implement common manipulations to the AnnoMatrix, maintaining the
|
||||
** norma guarantees around correctness of public API, eg,
|
||||
** - schema will be correct, including the "writable" attribute
|
||||
** - fetch() will return the latest data, even from views
|
||||
** - immutability guranteeds
|
||||
**
|
||||
** As most of these interfaces mutate the annoMatrix, they return a new
|
||||
** annoMatrix
|
||||
**
|
||||
** The actual implementation is in the sub-classes, which MUST override these.
|
||||
**/
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
|
||||
addObsAnnoCategory(col, category) {
|
||||
/*
|
||||
Add a new category value (aka "label") to a writable obs column, and return the new AnnoMatrix.
|
||||
Typical use is to add a new user-created label to a user-created obs categorical
|
||||
annotation.
|
||||
|
||||
Will throw column does not exist or is not writable.
|
||||
|
||||
Example:
|
||||
|
||||
addObsAnnoCategory("my cell type", "left toenail") -> AnnoMatrix
|
||||
|
||||
*/
|
||||
_subclassResponsibility();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
|
||||
async removeObsAnnoCategory(col, category, unassignedCategory) {
|
||||
/*
|
||||
Remove a category value from an obs column, reassign any obs having that value
|
||||
to the 'unassignedCategory' value, and return a promise for a new AnnoMatrix.
|
||||
Typical use is to remove a user-created label from a user-created obs categorical
|
||||
annotation.
|
||||
|
||||
Will throw column does not exist or is not writable.
|
||||
|
||||
An `unassignedCategory` value must be provided, for assignment to any obs/cells
|
||||
that had the now-delete category label as their value.
|
||||
|
||||
Example:
|
||||
await removeObsAnnoCategory("my-tissue-type", "right earlobe", "unassigned") -> AnnoMatrix
|
||||
|
||||
NOTE: method is async as it may need to fetch data to provide the reassignment.
|
||||
*/
|
||||
_subclassResponsibility();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
|
||||
dropObsColumn(col) {
|
||||
/*
|
||||
Drop an entire writable column, eg a user-created obs annotation. Typical use
|
||||
is to provide the "Delete Category" implementation. Returns the new AnnoMatrix.
|
||||
Will throw if not a writable annotation.
|
||||
|
||||
Will throw column does not exist or is not writable.
|
||||
|
||||
Example:
|
||||
|
||||
dropObsColumn("old annotations") -> AnnoMatrix
|
||||
*/
|
||||
_subclassResponsibility();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
|
||||
addObsColumn(colSchema, Ctor, value) {
|
||||
/*
|
||||
Add a new writable OBS annotation column, with the caller-specified schema, initial value
|
||||
type and value.
|
||||
|
||||
Value may be any one of:
|
||||
* an array of values
|
||||
* a primitive type, including null or undefined.
|
||||
If an array, length must be the same as 'this.nObs', and constructor must equal 'Ctor'.
|
||||
If a primitive, 'Ctor' will be used to create the initial value, which will be filled
|
||||
with 'value'.
|
||||
|
||||
Throws if the name specified in 'colSchema' duplicates an existing obs column.
|
||||
|
||||
Returns a new AnnoMatrix.
|
||||
|
||||
Examples:
|
||||
|
||||
addObsColumn(
|
||||
{ name: "foo", type: "categorical", categories: "unassigned" },
|
||||
Array,
|
||||
"unassigned"
|
||||
) -> AnnoMatrix
|
||||
|
||||
*/
|
||||
_subclassResponsibility();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
|
||||
renameObsColumn(oldCol, newCol) {
|
||||
/*
|
||||
Rename the obs column 'oldCol' to have name 'newCol' and returns new AnnoMatrix.
|
||||
|
||||
Will throw column does not exist or is not writable, or if 'newCol' is not unique.
|
||||
|
||||
Example:
|
||||
|
||||
renameObsColumn('cell type', 'old cell type') -> AnnoMatrix.
|
||||
|
||||
*/
|
||||
_subclassResponsibility();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
|
||||
async setObsColumnValues(col, obsLabels, value) {
|
||||
/*
|
||||
Set all obs with label in array 'obsLabels' to have 'value'. Typical use would be
|
||||
to set a group of cells to have a label on a user-created categorical anntoation
|
||||
(eg set all selected cells to have a label).
|
||||
|
||||
NOTE: async method, as it may need to fetch.
|
||||
|
||||
Will throw column does not exist or is not writable.
|
||||
|
||||
Example:
|
||||
await setObsColmnValues("flavor", [383, 400], "tasty") -> AnnoMtarix
|
||||
|
||||
*/
|
||||
_subclassResponsibility();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this, no-unused-vars -- make sure subclass implements
|
||||
async resetObsColumnValues(col, oldValue, newValue) {
|
||||
/*
|
||||
Set by value - all elements in the column with value 'oldValue' are set to 'newValue'.
|
||||
Async method - returns a promise for a new AnnoMatrix.
|
||||
|
||||
Typical use would be to set all labels of one value to another.
|
||||
|
||||
Will throw column does not exist or is not writable.
|
||||
|
||||
Example:
|
||||
await resetObsColumnValues("my notes", "good", "not-good") -> AnnoMatrix
|
||||
|
||||
*/
|
||||
_subclassResponsibility();
|
||||
}
|
||||
|
||||
/**
|
||||
** Private interfaces below.
|
||||
**/
|
||||
_resolveCachedQueries(field, queries) {
|
||||
return queries
|
||||
.map((query) =>
|
||||
_whereCacheGet(this._whereCache, this.schema, field, query).filter(
|
||||
(cacheKey) =>
|
||||
cacheKey !== undefined && this._cache[field].hasCol(cacheKey)
|
||||
)
|
||||
)
|
||||
.flat();
|
||||
}
|
||||
|
||||
async _fetch(field, q) {
|
||||
if (!AnnoMatrix.fields().includes(field)) return undefined;
|
||||
const queries = Array.isArray(q) ? q : [q];
|
||||
|
||||
/* find cached columns we need, and GC the rest */
|
||||
const cachedColumns = this._resolveCachedQueries(field, queries);
|
||||
this._gcFetchCleanup(field, cachedColumns);
|
||||
|
||||
/* find any query not already cached */
|
||||
const uncachedQueries = queries.filter((query) =>
|
||||
_whereCacheGet(this._whereCache, this.schema, field, query).some(
|
||||
(cacheKey) =>
|
||||
cacheKey === undefined || !this._cache[field].hasCol(cacheKey)
|
||||
)
|
||||
);
|
||||
|
||||
/* load uncached queries */
|
||||
if (uncachedQueries.length > 0) {
|
||||
await Promise.all(
|
||||
uncachedQueries.map((query) =>
|
||||
this._getPendingLoad(field, query, async (_field, _query) => {
|
||||
/* fetch, then index. _doLoad is subclass interface */
|
||||
const [whereCacheUpdate, df] = await this._doLoad(_field, _query);
|
||||
this._cache[_field] = this._cache[_field].withColsFrom(df);
|
||||
this._whereCache = _whereCacheMerge(
|
||||
this._whereCache,
|
||||
whereCacheUpdate
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/* everything we need is in the cache, so just cherry-pick requested columns */
|
||||
const requestedCacheKeys = this._resolveCachedQueries(field, queries);
|
||||
const response = this._cache[field].subset(null, requestedCacheKeys);
|
||||
this._gcUpdateStats(field, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
async _getPendingLoad(field, query, fetchFn) {
|
||||
/*
|
||||
Given a query on a field, ensure that we only have a single outstanding
|
||||
fetch at any given time. If multiple requests occur while a fetch is
|
||||
outstanding, just wait for the original.
|
||||
|
||||
This is implemented by returning a promise that will await the singular
|
||||
fetch promise.
|
||||
*/
|
||||
const key = _queryCacheKey(field, query);
|
||||
if (!this._pendingLoad[field][key]) {
|
||||
this._pendingLoad[field][key] = fetchFn(field, query);
|
||||
try {
|
||||
await this._pendingLoad[field][key];
|
||||
} finally {
|
||||
delete this._pendingLoad[field][key];
|
||||
}
|
||||
}
|
||||
return this._pendingLoad[field][key];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this -- make sure subclass implements
|
||||
async _doLoad() {
|
||||
_subclassResponsibility();
|
||||
}
|
||||
|
||||
/**
|
||||
** Garbage collection of annomatrix cache to manage memory use.
|
||||
**/
|
||||
|
||||
/*
|
||||
These callbacks implement a GC policy for the cache. Background:
|
||||
|
||||
* For the Loader (base) annomatrix, re-filling the cache is expensive as
|
||||
it requires an HTTP fetch.
|
||||
* user-defined / writable columns must not be GC'ed as they may be
|
||||
still pending a save/commit.
|
||||
* For views, cost is less and (roughly) proportional with nObs
|
||||
* obs, var and emb do not grow without bounds, and are needed constantly
|
||||
for rendering.
|
||||
a) There is no upside to GC'ing these in the base (loader)
|
||||
b) The undo/redo cache can hold a large number in views, which is worht GC'ing
|
||||
* X is often much larger than memory, and the UI allows add/del from
|
||||
this. Most of the GC potential is here in both the base and views.
|
||||
|
||||
Current policy:
|
||||
* if in active use ("hot") do not GC obs, var or emb.
|
||||
* never, ever GC writable obs columns
|
||||
* For base/loader set a numeric limit on maximum X column count
|
||||
* For views, apply a fixed limit to the number of columns cached in any field.
|
||||
Limit will be lower if not hot.
|
||||
|
||||
To be effective, the GC callback needs to be invoked from the undo/redo code,
|
||||
as much of the cache is pinned by that data structure.
|
||||
*/
|
||||
_gcField(field, isHot, pinnedColumns) {
|
||||
const maxColumns = isHot ? 256 : 10; // maybe to aggessive?
|
||||
|
||||
const cache = this._cache[field];
|
||||
if (cache.colIndex.size() < maxColumns) return; // trivial rejection
|
||||
|
||||
const candidates = cache.colIndex
|
||||
.labels()
|
||||
.filter((col) => !pinnedColumns.includes(col));
|
||||
|
||||
const excessCount = candidates.length + pinnedColumns.length - maxColumns;
|
||||
if (excessCount > 0) {
|
||||
const { _gcInfo } = this;
|
||||
candidates.sort((a, b) => {
|
||||
let atime = _gcInfo.get(_columnCacheKey(field, a));
|
||||
if (atime === undefined) atime = 0;
|
||||
|
||||
let btime = _gcInfo.get(_columnCacheKey(field, b));
|
||||
if (btime === undefined) btime = 0;
|
||||
|
||||
return atime - btime;
|
||||
});
|
||||
|
||||
const toDrop = candidates.slice(0, excessCount);
|
||||
// helpful debugging - please leave in place.
|
||||
// console.log(
|
||||
// `GC: dropping from ${field} hot:${isHot}, columns [${toDrop.join(
|
||||
// ", "
|
||||
// )}]`
|
||||
// );
|
||||
this._cache[field] = toDrop.reduce(
|
||||
(df, col) => df.dropCol(col),
|
||||
this._cache[field]
|
||||
);
|
||||
toDrop.forEach((col) => _gcInfo.delete(_columnCacheKey(field, col)));
|
||||
}
|
||||
}
|
||||
|
||||
_gcFetchCleanup(field, pinnedColumns) {
|
||||
/*
|
||||
Called during data load/fetch. By definition, this is 'hot', so we
|
||||
only want to gc X.
|
||||
*/
|
||||
if (field === "X") {
|
||||
this._gcField(
|
||||
field,
|
||||
true,
|
||||
pinnedColumns.concat(_getWritableColumns(this.schema, field))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_gc(hints) {
|
||||
/*
|
||||
Called from middleware, or elsewhere. isHot is true if we are in the active store,
|
||||
or false if we are in some other context (eg, history state).
|
||||
*/
|
||||
const { isHot } = hints;
|
||||
const candidateFields = isHot ? ["X"] : ["X", "emb", "var", "obs"];
|
||||
candidateFields.forEach((field) =>
|
||||
this._gcField(field, isHot, _getWritableColumns(this.schema, field))
|
||||
);
|
||||
}
|
||||
|
||||
_gcUpdateStats(field, dataframe) {
|
||||
/*
|
||||
called each time a query is performed, allowing the gc to update any bookkeeping
|
||||
information. Currently, this is just a simple last-fetched timestamp, stored
|
||||
in a Map.
|
||||
|
||||
Map objects preserve order of insertion. This is leveraged as a cheap way to
|
||||
do LRU, by removing and re-inserting keys. IMPORTANT: the cleanup code assumes
|
||||
the map insertion order is least-recently-used first.
|
||||
*/
|
||||
const cols = dataframe.colIndex.labels();
|
||||
const { _gcInfo } = this;
|
||||
const now = Date.now();
|
||||
cols.forEach((c) => {
|
||||
// gcInfo.delete(c);
|
||||
_gcInfo.set(_columnCacheKey(field, c), now);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
Cloning sublcass protocol - we rely in cloning to preserve immutable
|
||||
symantics while not causing races or other side effects in internal
|
||||
cache management.
|
||||
|
||||
Subclasses must override _cloneDeeper() if they have state which requires
|
||||
something other than a shallow copy. Overrides MUST call super()._cloneDeepr(),
|
||||
and return its result (after any required modification). _cloneDeeper()
|
||||
will be called on the OLD object, with the NEW object as an argument.
|
||||
|
||||
Do not override _clone();
|
||||
**/
|
||||
_cloneDeeper(clone) {
|
||||
clone._cache = _shallowClone(this._cache);
|
||||
clone._gcInfo = new Map();
|
||||
clone._pendingLoad = {
|
||||
obs: {},
|
||||
var: {},
|
||||
emb: {},
|
||||
X: {},
|
||||
};
|
||||
return clone;
|
||||
}
|
||||
|
||||
_clone() {
|
||||
const clone = _shallowClone(this);
|
||||
this._cloneDeeper(clone);
|
||||
Object.seal(clone);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
private utility functions below
|
||||
*/
|
||||
|
||||
function _queryCacheKey(field, query) {
|
||||
if (typeof query === "object") {
|
||||
const { field: queryField, column: queryColumn, value: queryValue } = query;
|
||||
return `${field}/${queryField}/${queryColumn}/${queryValue}`;
|
||||
}
|
||||
return `${field}/${query}`;
|
||||
}
|
||||
|
||||
function _columnCacheKey(field, column) {
|
||||
return `${field}/${column}`;
|
||||
}
|
||||
|
||||
function _subclassResponsibility() {
|
||||
/* protect against bugs in subclass */
|
||||
throw new Error("subclass failed to implement required method");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
Shallow clone an object, correctly handling prototype
|
||||
*/
|
||||
export default function _shallowClone(orig) {
|
||||
return Object.assign(Object.create(Object.getPrototypeOf(orig)), orig);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
Row crossfilter proxy for an AnnoMatrix. This wraps Crossfilter,
|
||||
providing a number of services, and ensuring that the crossfilter and
|
||||
AnnoMatrix stay in sync:
|
||||
- on-demand index creation as data is loaded
|
||||
- transparently mapping between queries and crossfilter index names.
|
||||
- for mutation of the matrix by user annotations, maintain synchronization
|
||||
between Crossfilter and AnnoMatrix.
|
||||
*/
|
||||
import Crossfilter from "../util/typedCrossfilter";
|
||||
import { _getColumnSchema } from "./schema";
|
||||
|
||||
function _dimensionNameFromDf(field, df) {
|
||||
const colNames = df.colIndex.labels();
|
||||
return _dimensionName(field, colNames);
|
||||
}
|
||||
|
||||
function _dimensionName(field, colNames) {
|
||||
if (!Array.isArray(colNames)) return `${field}/${colNames}`;
|
||||
return `${field}/${colNames.join(":")}`;
|
||||
}
|
||||
|
||||
export default class AnnoMatrixObsCrossfilter {
|
||||
constructor(annoMatrix, _obsCrossfilter = null) {
|
||||
this.annoMatrix = annoMatrix;
|
||||
this.obsCrossfilter =
|
||||
_obsCrossfilter || new Crossfilter(annoMatrix._cache.obs);
|
||||
this.obsCrossfilter = this.obsCrossfilter.setData(annoMatrix._cache.obs);
|
||||
}
|
||||
|
||||
size() {
|
||||
return this.obsCrossfilter.size();
|
||||
}
|
||||
|
||||
/**
|
||||
Managing the associated annoMatrix. These wrappers are necessary to
|
||||
make coordinated changes to BOTH the crossfilter and annoMatrix, and
|
||||
ensure that all state stays synchronized.
|
||||
|
||||
See API documentation in annoMatrix.js.
|
||||
**/
|
||||
addObsColumn(colSchema, Ctor, value) {
|
||||
const annoMatrix = this.annoMatrix.addObsColumn(colSchema, Ctor, value);
|
||||
const obsCrossfilter = this.obsCrossfilter.setData(annoMatrix._cache.obs);
|
||||
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||
}
|
||||
|
||||
dropObsColumn(col) {
|
||||
const annoMatrix = this.annoMatrix.dropObsColumn(col);
|
||||
let { obsCrossfilter } = this;
|
||||
const dimName = _dimensionName("obs", col);
|
||||
if (obsCrossfilter.hasDimension(dimName)) {
|
||||
obsCrossfilter = obsCrossfilter.delDimension(dimName);
|
||||
}
|
||||
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||
}
|
||||
|
||||
renameObsColumn(oldCol, newCol) {
|
||||
const annoMatrix = this.annoMatrix.renameObsColumn(oldCol, newCol);
|
||||
const oldDimName = _dimensionName("obs", oldCol);
|
||||
const newDimName = _dimensionName("obs", newCol);
|
||||
let { obsCrossfilter } = this;
|
||||
if (obsCrossfilter.hasDimension(oldDimName)) {
|
||||
obsCrossfilter = obsCrossfilter.renameDimension(oldDimName, newDimName);
|
||||
}
|
||||
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||
}
|
||||
|
||||
addObsAnnoCategory(col, category) {
|
||||
const annoMatrix = this.annoMatrix.addObsAnnoCategory(col, category);
|
||||
const dimName = _dimensionName("obs", col);
|
||||
let { obsCrossfilter } = this;
|
||||
if (obsCrossfilter.hasDimension(dimName)) {
|
||||
obsCrossfilter = obsCrossfilter.delDimension(dimName);
|
||||
}
|
||||
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||
}
|
||||
|
||||
async removeObsAnnoCategory(col, category, unassignedCategory) {
|
||||
const annoMatrix = await this.annoMatrix.removeObsAnnoCategory(
|
||||
col,
|
||||
category,
|
||||
unassignedCategory
|
||||
);
|
||||
const dimName = _dimensionName("obs", col);
|
||||
let { obsCrossfilter } = this;
|
||||
if (obsCrossfilter.hasDimension(dimName)) {
|
||||
obsCrossfilter = obsCrossfilter.delDimension(dimName);
|
||||
}
|
||||
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||
}
|
||||
|
||||
async setObsColumnValues(col, rowLabels, value) {
|
||||
const annoMatrix = await this.annoMatrix.setObsColumnValues(
|
||||
col,
|
||||
rowLabels,
|
||||
value
|
||||
);
|
||||
const dimName = _dimensionName("obs", col);
|
||||
let { obsCrossfilter } = this;
|
||||
if (obsCrossfilter.hasDimension(dimName)) {
|
||||
obsCrossfilter = obsCrossfilter.delDimension(dimName);
|
||||
}
|
||||
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||
}
|
||||
|
||||
async resetObsColumnValues(col, oldValue, newValue) {
|
||||
const annoMatrix = await this.annoMatrix.resetObsColumnValues(
|
||||
col,
|
||||
oldValue,
|
||||
newValue
|
||||
);
|
||||
const dimName = _dimensionName("obs", col);
|
||||
let { obsCrossfilter } = this;
|
||||
if (obsCrossfilter.hasDimension(dimName)) {
|
||||
obsCrossfilter = obsCrossfilter.delDimension(dimName);
|
||||
}
|
||||
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||
}
|
||||
|
||||
/**
|
||||
Selection state - API is identical to ImmutableTypedCrossfilter, as these
|
||||
are just wrappers to lazy create indices.
|
||||
**/
|
||||
|
||||
async select(field, query, spec) {
|
||||
const { annoMatrix } = this;
|
||||
let { obsCrossfilter } = this;
|
||||
|
||||
if (!annoMatrix?._cache?.[field]) {
|
||||
throw new Error("Unknown field name");
|
||||
}
|
||||
if (field === "var") {
|
||||
throw new Error("unable to obsSelect upon the var dimension");
|
||||
}
|
||||
|
||||
// grab the data, so we can grab the index.
|
||||
const df = await annoMatrix.fetch(field, query);
|
||||
|
||||
const dimName = _dimensionNameFromDf(field, df);
|
||||
if (!obsCrossfilter.hasDimension(dimName)) {
|
||||
// lazy index generation - add dimension when first used
|
||||
obsCrossfilter = this._addObsCrossfilterDimension(
|
||||
annoMatrix,
|
||||
obsCrossfilter,
|
||||
field,
|
||||
df
|
||||
);
|
||||
}
|
||||
|
||||
// select
|
||||
obsCrossfilter = obsCrossfilter.select(dimName, spec);
|
||||
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||
}
|
||||
|
||||
selectAll() {
|
||||
/*
|
||||
Select all on any dimension in this field.
|
||||
*/
|
||||
const { annoMatrix } = this;
|
||||
const currentDims = this.obsCrossfilter.dimensionNames();
|
||||
const obsCrossfilter = currentDims.reduce((xfltr, dim) => {
|
||||
return xfltr.select(dim, { mode: "all" });
|
||||
}, this.obsCrossfilter);
|
||||
return new AnnoMatrixObsCrossfilter(annoMatrix, obsCrossfilter);
|
||||
}
|
||||
|
||||
countSelected() {
|
||||
/* if no data yet indexed in the crossfilter, just say everything is selected */
|
||||
if (this.obsCrossfilter.size() === 0) return this.annoMatrix.nObs;
|
||||
return this.obsCrossfilter.countSelected();
|
||||
}
|
||||
|
||||
allSelectedMask() {
|
||||
/* if no data yet indexed in the crossfilter, just say everything is selected */
|
||||
if (
|
||||
this.obsCrossfilter.size() === 0 ||
|
||||
this.obsCrossfilter.dimensionNames().length === 0
|
||||
) {
|
||||
/* fake the mask */
|
||||
return new Uint8Array(this.annoMatrix.nObs).fill(1);
|
||||
}
|
||||
return this.obsCrossfilter.allSelectedMask();
|
||||
}
|
||||
|
||||
allSelectedLabels() {
|
||||
/* if no data yet indexed in the crossfilter, just say everything is selected */
|
||||
if (
|
||||
this.obsCrossfilter.size() === 0 ||
|
||||
this.obsCrossfilter.dimensionNames().length === 0
|
||||
) {
|
||||
return this.annoMatrix.rowIndex.labels();
|
||||
}
|
||||
|
||||
const mask = this.obsCrossfilter.allSelectedMask();
|
||||
const index = this.annoMatrix.rowIndex.isubsetMask(mask);
|
||||
return index.labels();
|
||||
}
|
||||
|
||||
fillByIsSelected(array, selectedValue, deselectedValue) {
|
||||
/* if no data yet indexed in the crossfilter, just say everything is selected */
|
||||
if (
|
||||
this.obsCrossfilter.size() === 0 ||
|
||||
this.obsCrossfilter.dimensionNames().length === 0
|
||||
) {
|
||||
return array.fill(selectedValue);
|
||||
}
|
||||
return this.obsCrossfilter.fillByIsSelected(
|
||||
array,
|
||||
selectedValue,
|
||||
deselectedValue
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
** Private below
|
||||
**/
|
||||
|
||||
_addObsCrossfilterDimension(annoMatrix, obsCrossfilter, field, df) {
|
||||
if (field === "var") return obsCrossfilter;
|
||||
const dimName = _dimensionNameFromDf(field, df);
|
||||
const dimParams = this._getObsDimensionParams(field, df);
|
||||
obsCrossfilter = obsCrossfilter.setData(annoMatrix._cache.obs);
|
||||
obsCrossfilter = obsCrossfilter.addDimension(dimName, ...dimParams);
|
||||
return obsCrossfilter;
|
||||
}
|
||||
|
||||
_getColumnBaseType(field, col) {
|
||||
/* Look up the primitive type for this field/col */
|
||||
const colSchema = _getColumnSchema(this.annoMatrix.schema, field, col);
|
||||
return colSchema.type;
|
||||
}
|
||||
|
||||
_getObsDimensionParams(field, df) {
|
||||
/* return the crossfilter dimensiontype type and params for this field/dataframe */
|
||||
|
||||
if (field === "emb") {
|
||||
/* assumed to be 2D */
|
||||
return ["spatial", df.icol(0).asArray(), df.icol(1).asArray()];
|
||||
}
|
||||
|
||||
/* assumed to be 1D */
|
||||
const col = df.icol(0);
|
||||
const colName = df.colIndex.getLabel(0);
|
||||
const type = this._getColumnBaseType(field, colName);
|
||||
if (type === "string" || type === "categorical" || type === "boolean") {
|
||||
return ["enum", col.asArray()];
|
||||
}
|
||||
if (type === "int32") {
|
||||
return ["scalar", col.asArray(), Int32Array];
|
||||
}
|
||||
if (type === "float32") {
|
||||
return ["scalar", col.asArray(), Float32Array];
|
||||
}
|
||||
// Currently not supporting boolean and categorical types.
|
||||
console.error(
|
||||
`Warning - unknown metadata schema (${type}) for field ${field} ${colName}.`
|
||||
);
|
||||
// skip it - we don't know what to do with this type
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export { doBinaryRequest } from "../util/actionHelpers";
|
||||
|
||||
/* double URI encode - needed for query-param filters */
|
||||
export function _dubEncURIComp(s) {
|
||||
return encodeURIComponent(encodeURIComponent(s));
|
||||
}
|
||||
|
||||
/* currently unused, consider deleting */
|
||||
export function _fetchResult(promise) {
|
||||
let _status = "pending";
|
||||
const res = promise.then(
|
||||
(r) => {
|
||||
_status = "success";
|
||||
return r;
|
||||
},
|
||||
(e) => {
|
||||
_status = "error";
|
||||
throw e;
|
||||
}
|
||||
);
|
||||
|
||||
res.status = () => {
|
||||
return _status;
|
||||
};
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
AnnoMatrix -- Annotated Matrix exported interface
|
||||
|
||||
Public API is defined in:
|
||||
|
||||
annoMatrix.js
|
||||
viewCreators.js
|
||||
crossfilter.js
|
||||
|
||||
*/
|
||||
|
||||
export { default as AnnoMatrixLoader } from "./loader";
|
||||
export * from "./viewCreators";
|
||||
export { default as AnnoMatrixObsCrossfilter } from "./crossfilter";
|
||||
export { default as gcMiddleware } from "./middleware";
|
||||
@@ -0,0 +1,287 @@
|
||||
import { doBinaryRequest, _dubEncURIComp } from "./fetchHelpers";
|
||||
import { matrixFBSToDataframe } from "../util/stateManager/matrix";
|
||||
import { _getColumnSchema, _normalizeCategoricalSchema } from "./schema";
|
||||
import {
|
||||
addObsAnnoColumn,
|
||||
removeObsAnnoColumn,
|
||||
addObsAnnoCategory,
|
||||
removeObsAnnoCategory,
|
||||
} from "../util/stateManager/schemaHelpers";
|
||||
import { isArrayOrTypedArray } from "../util/typeHelpers";
|
||||
import { _whereCacheCreate } from "./whereCache";
|
||||
import AnnoMatrix from "./annoMatrix";
|
||||
import PromiseLimit from "../util/promiseLimit";
|
||||
|
||||
const promiseThrottle = new PromiseLimit(5);
|
||||
|
||||
export default class AnnoMatrixLoader extends AnnoMatrix {
|
||||
/*
|
||||
AnnoMatrix implementation which proxies to HTTP server using the CXG REST API.
|
||||
Used as the base (non-view) instance.
|
||||
|
||||
Public API is same as AnnoMatrix class (refer there for API description),
|
||||
with the addition of the constructor which bootstraps:
|
||||
|
||||
new AnnoMatrixLoader(serverBaseURL, schema) -> instance
|
||||
|
||||
*/
|
||||
constructor(baseURL, schema) {
|
||||
const { nObs, nVar } = schema.dataframe;
|
||||
super(schema, nObs, nVar);
|
||||
|
||||
if (baseURL[baseURL.length - 1] !== "/") {
|
||||
// must have trailing slash
|
||||
baseURL += "/";
|
||||
}
|
||||
this.baseURL = baseURL;
|
||||
Object.seal(this);
|
||||
}
|
||||
|
||||
/**
|
||||
** Public. API described in base class.
|
||||
**/
|
||||
addObsAnnoCategory(col, category) {
|
||||
/*
|
||||
Add a new category (aka label) to the schema for an obs column.
|
||||
*/
|
||||
const colSchema = _getColumnSchema(this.schema, "obs", col);
|
||||
_writableCategoryTypeCheck(colSchema); // throws on error
|
||||
|
||||
const o = this._clone();
|
||||
o.schema = addObsAnnoCategory(this.schema, col, category);
|
||||
return o;
|
||||
}
|
||||
|
||||
async removeObsAnnoCategory(col, category, unassignedCategory) {
|
||||
/*
|
||||
Remove a single "category" (aka "label") from the data & schema of an obs column.
|
||||
*/
|
||||
const colSchema = _getColumnSchema(this.schema, "obs", col);
|
||||
_writableCategoryTypeCheck(colSchema); // throws on error
|
||||
|
||||
const o = await this.resetObsColumnValues(
|
||||
col,
|
||||
category,
|
||||
unassignedCategory
|
||||
);
|
||||
o.schema = removeObsAnnoCategory(o.schema, col, category);
|
||||
return o;
|
||||
}
|
||||
|
||||
dropObsColumn(col) {
|
||||
/*
|
||||
drop column from field
|
||||
*/
|
||||
const colSchema = _getColumnSchema(this.schema, "obs", col);
|
||||
_writableCheck(colSchema); // throws on error
|
||||
|
||||
const o = this._clone();
|
||||
o._cache.obs = this._cache.obs.dropCol(col);
|
||||
o.schema = removeObsAnnoColumn(this.schema, col);
|
||||
return o;
|
||||
}
|
||||
|
||||
addObsColumn(colSchema, Ctor, value) {
|
||||
/*
|
||||
add a column to field, initializing with value. Value may
|
||||
be one of:
|
||||
* an array of values
|
||||
* a primitive type, including null or undefined.
|
||||
If an array, it must be of same size as nObs and same type as Ctor
|
||||
*/
|
||||
colSchema.writable = true;
|
||||
const col = colSchema.name;
|
||||
if (
|
||||
_getColumnSchema(this.schema, "obs", col) ||
|
||||
this._cache.obs.hasCol(col)
|
||||
) {
|
||||
throw new Error("column already exists");
|
||||
}
|
||||
|
||||
const o = this._clone();
|
||||
let data;
|
||||
if (isArrayOrTypedArray(value)) {
|
||||
if (value.constructor !== Ctor)
|
||||
throw new Error("Mismatched value array type");
|
||||
if (value.length !== this.nObs)
|
||||
throw new Error("Value array has incorrect length");
|
||||
data = value.slice();
|
||||
} else {
|
||||
data = new Ctor(this.nObs).fill(value);
|
||||
}
|
||||
o._cache.obs = this._cache.obs.withCol(col, data);
|
||||
o.schema = addObsAnnoColumn(this.schema, col, {
|
||||
...colSchema,
|
||||
writable: true,
|
||||
});
|
||||
return o;
|
||||
}
|
||||
|
||||
renameObsColumn(oldCol, newCol) {
|
||||
/*
|
||||
Rename the obs oldColName to newColName. oldCol must be writable.
|
||||
*/
|
||||
const oldColSchema = _getColumnSchema(this.schema, "obs", oldCol);
|
||||
_writableCheck(oldColSchema); // throws on error
|
||||
|
||||
const value = this._cache.obs.hasCol(oldCol)
|
||||
? this._cache.obs.col(oldCol).asArray()
|
||||
: undefined;
|
||||
return this.dropObsColumn(oldCol).addObsColumn(
|
||||
{
|
||||
...oldColSchema,
|
||||
name: newCol,
|
||||
},
|
||||
value.constructor,
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
async setObsColumnValues(col, rowLabels, value) {
|
||||
/*
|
||||
Set all rows identified by rowLabels to value.
|
||||
*/
|
||||
const colSchema = _getColumnSchema(this.schema, "obs", col);
|
||||
_writableCategoryTypeCheck(colSchema); // throws on error
|
||||
|
||||
// ensure that we have the data in cache before we manipulate it
|
||||
await this.fetch("obs", col);
|
||||
if (!this._cache.obs.hasCol(col))
|
||||
throw new Error("Internal error - user annotation data missing");
|
||||
|
||||
const rowIndices = this.rowIndex.getOffsets(rowLabels);
|
||||
const data = this._cache.obs.col(col).asArray().slice();
|
||||
for (let i = 0, len = rowIndices.length; i < len; i += 1) {
|
||||
const idx = rowIndices[i];
|
||||
if (idx === undefined) throw new Error("Unknown row label");
|
||||
data[idx] = value;
|
||||
}
|
||||
|
||||
const o = this._clone();
|
||||
o._cache.obs = this._cache.obs.replaceColData(col, data);
|
||||
const { categories } = colSchema;
|
||||
if (!categories?.includes(value)) {
|
||||
o.schema = addObsAnnoCategory(this.schema, col, value);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
async resetObsColumnValues(col, oldValue, newValue) {
|
||||
/*
|
||||
Set all rows with value 'oldValue' to 'newValue'.
|
||||
*/
|
||||
const colSchema = _getColumnSchema(this.schema, "obs", col);
|
||||
_writableCategoryTypeCheck(colSchema); // throws on error
|
||||
|
||||
if (!colSchema.categories.includes(oldValue)) {
|
||||
throw new Error("unknown category");
|
||||
}
|
||||
|
||||
// ensure that we have the data in cache before we manipulate it
|
||||
await this.fetch("obs", col);
|
||||
if (!this._cache.obs.hasCol(col))
|
||||
throw new Error("Internal error - user annotation data missing");
|
||||
|
||||
const data = this._cache.obs.col(col).asArray().slice();
|
||||
for (let i = 0, l = data.length; i < l; i += 1) {
|
||||
if (data[i] === oldValue) data[i] = newValue;
|
||||
}
|
||||
|
||||
const o = this._clone();
|
||||
o._cache.obs = this._cache.obs.replaceColData(col, data);
|
||||
const { categories } = colSchema;
|
||||
if (!categories?.includes(newValue)) {
|
||||
o.schema = addObsAnnoCategory(this.schema, col, newValue);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
/**
|
||||
** Private below
|
||||
**/
|
||||
async _doLoad(field, query) {
|
||||
/*
|
||||
_doLoad - evaluates the query against the field. Returns:
|
||||
* whereCache update: column query map mapping the query to the column labels
|
||||
* Dataframe containing the new colums (one per dimension)
|
||||
*/
|
||||
let urlQuery;
|
||||
let urlBase;
|
||||
let priority = 10; // default fetch priority
|
||||
|
||||
switch (field) {
|
||||
case "obs":
|
||||
case "var": {
|
||||
urlBase = `${this.baseURL}annotations/${field}`;
|
||||
urlQuery = _encodeQuery("annotation-name", query);
|
||||
break;
|
||||
}
|
||||
case "X": {
|
||||
urlBase = `${this.baseURL}data/var`;
|
||||
urlQuery = _encodeQuery(undefined, query);
|
||||
break;
|
||||
}
|
||||
case "emb": {
|
||||
urlBase = `${this.baseURL}layout/obs`;
|
||||
urlQuery = _encodeQuery("layout-name", query);
|
||||
priority = 0; // high prio load for embeddings
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error("Unknown field name");
|
||||
}
|
||||
|
||||
const url = `${urlBase}?${urlQuery}`;
|
||||
const buffer = await promiseThrottle.priorityAdd(
|
||||
priority,
|
||||
doBinaryRequest,
|
||||
url
|
||||
);
|
||||
const result = matrixFBSToDataframe(buffer);
|
||||
if (!result || result.isEmpty()) throw Error("Unknown field/col");
|
||||
|
||||
const whereCacheUpdate = _whereCacheCreate(
|
||||
field,
|
||||
query,
|
||||
result.colIndex.labels()
|
||||
);
|
||||
|
||||
if (field === "obs") {
|
||||
/* cough, cough - see comment on method */
|
||||
_normalizeCategoricalSchema(
|
||||
this.schema.annotations.obsByName[query],
|
||||
result.col(query)
|
||||
);
|
||||
}
|
||||
|
||||
return [whereCacheUpdate, result];
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Utility functions below
|
||||
*/
|
||||
|
||||
function _encodeQuery(colKey, q) {
|
||||
if (typeof q === "object") {
|
||||
const { field: queryField, column: queryColumn, value: queryValue } = q;
|
||||
return `${_dubEncURIComp(queryField)}:${_dubEncURIComp(
|
||||
queryColumn
|
||||
)}=${_dubEncURIComp(queryValue)}`;
|
||||
}
|
||||
if (!colKey) throw new Error("Unsupported query by name");
|
||||
return `${colKey}=${encodeURIComponent(q)}`;
|
||||
}
|
||||
|
||||
function _writableCheck(colSchema) {
|
||||
if (!colSchema?.writable) {
|
||||
throw new Error("Unknown or readonly obs column");
|
||||
}
|
||||
}
|
||||
|
||||
function _writableCategoryTypeCheck(colSchema) {
|
||||
_writableCheck(colSchema);
|
||||
if (colSchema.type !== "categorical") {
|
||||
throw new Error("column must be categorical");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
Garbage collection / cache management support
|
||||
|
||||
Middleware that knows how to pull annoMatrix from the undoable state,
|
||||
and pass it along to the AnnoMatrix class for possible cache GC.
|
||||
|
||||
Private interface.
|
||||
|
||||
Future work item: this middleware knows internal details of both the
|
||||
Undoable metareducer and the AnnoMatrix private API. It would be helpful
|
||||
to make the Undoable interface better factored.
|
||||
*/
|
||||
|
||||
const annoMatrixGC = (store) => (next) => (action) => {
|
||||
if (_itIsTimeForGC()) {
|
||||
_doGC(store);
|
||||
}
|
||||
return next(action);
|
||||
};
|
||||
|
||||
let lastGCTime = 0;
|
||||
const InterGCDelayMS = 30 * 1000; // 30 seconds
|
||||
function _itIsTimeForGC() {
|
||||
/*
|
||||
we don't want to run GC on every dispatch, so throttle it a bit.
|
||||
|
||||
Runs every InterGCDelay period
|
||||
*/
|
||||
const now = Date.now();
|
||||
if (now - lastGCTime > InterGCDelayMS) {
|
||||
lastGCTime = now;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function _doGC(store) {
|
||||
const state = store.getState();
|
||||
|
||||
// these should probably be a function imported from undoable.js, etc, as
|
||||
// they have overly intimiate knowledge of our reducers.
|
||||
const undoablePast = state["@@undoable/past"];
|
||||
const undoableFuture = state["@@undoable/future"];
|
||||
const undoableStack = undoablePast
|
||||
.concat(undoableFuture)
|
||||
.flatMap((snapshot) =>
|
||||
snapshot.filter((v) => v[0] === "annoMatrix").map((v) => v[1])
|
||||
);
|
||||
const currentAnnoMatrix = state.annoMatrix;
|
||||
|
||||
/*
|
||||
We want to identify those matrixes currently "hot", ie, linked from the current annoMatrix,
|
||||
as our current gc algo is more aggressive with those not hot.
|
||||
*/
|
||||
const allAnnoMatrices = new Map(
|
||||
undoableStack.map((m) => [m, { isHot: false }])
|
||||
);
|
||||
let am = currentAnnoMatrix;
|
||||
while (am) {
|
||||
allAnnoMatrices.set(am, { isHot: true });
|
||||
am = am.viewOf;
|
||||
}
|
||||
allAnnoMatrices.forEach((hints, annoMatrix) => annoMatrix._gc(hints));
|
||||
}
|
||||
|
||||
export default annoMatrixGC;
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Private helper functions related to schema
|
||||
*/
|
||||
import catLabelSort from "../util/catLabelSort";
|
||||
import { unassignedCategoryLabel } from "../globals";
|
||||
|
||||
export function _getColumnSchema(schema, field, col) {
|
||||
/* look up the column definition */
|
||||
switch (field) {
|
||||
case "obs":
|
||||
if (typeof col === "object")
|
||||
throw new Error("unable to get column schema by query");
|
||||
return schema.annotations.obsByName[col];
|
||||
case "var":
|
||||
if (typeof col === "object")
|
||||
throw new Error("unable to get column schema by query");
|
||||
return schema.annotations.varByName[col];
|
||||
case "emb":
|
||||
if (typeof col === "object")
|
||||
throw new Error("unable to get column schema by query");
|
||||
return schema.layout.obsByName[col];
|
||||
case "X":
|
||||
return schema.dataframe;
|
||||
default:
|
||||
throw new Error(`unknown field name: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function _getColumnDimensionNames(schema, field, col) {
|
||||
/*
|
||||
field/col may be an alias for multiple columns. Currently used to map ND
|
||||
values to 1D dataframe columns for embeddings/layout. Signfied by the presence
|
||||
of the "dims" value in the schema.
|
||||
*/
|
||||
const colSchema = _getColumnSchema(schema, field, col);
|
||||
if (!colSchema) {
|
||||
return undefined;
|
||||
}
|
||||
return colSchema.dims || [col];
|
||||
}
|
||||
|
||||
export function _schemaColumns(schema, field) {
|
||||
switch (field) {
|
||||
case "obs":
|
||||
return Object.keys(schema.annotations.obsByName);
|
||||
case "var":
|
||||
return Object.keys(schema.annotations.varByName);
|
||||
case "emb":
|
||||
return Object.keys(schema.layout.obsByName);
|
||||
default:
|
||||
throw new Error(`unknown field name: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function _getWritableColumns(schema, field) {
|
||||
if (field !== "obs") return [];
|
||||
return schema.annotations.obs.columns
|
||||
.filter((v) => v.writable)
|
||||
.map((v) => v.name);
|
||||
}
|
||||
|
||||
export function _isContinuousType(schema) {
|
||||
const { type } = schema;
|
||||
return !(type === "string" || type === "boolean" || type === "categorical");
|
||||
}
|
||||
|
||||
export function _normalizeCategoricalSchema(colSchema, col) {
|
||||
const { type, writable } = colSchema;
|
||||
if (type === "string" || type === "boolean" || type === "categorical") {
|
||||
const categorySet = new Set(
|
||||
col.summarize().categories.concat(colSchema.categories ?? [])
|
||||
);
|
||||
if (writable && !categorySet.has(unassignedCategoryLabel)) {
|
||||
categorySet.add(unassignedCategoryLabel);
|
||||
}
|
||||
colSchema.categories = Array.from(categorySet);
|
||||
}
|
||||
|
||||
if (colSchema.categories) {
|
||||
colSchema.categories = catLabelSort(writable, colSchema.categories);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
View creators. These are helper functions which create new views from existing
|
||||
instances of AnnoMatrix, implementing common UI functions.
|
||||
*/
|
||||
|
||||
import { AnnoMatrixRowSubsetView, AnnoMatrixClipView } from "./views";
|
||||
|
||||
export function isubsetMask(annoMatrix, obsMask) {
|
||||
/*
|
||||
Subset annomatrix to contain the rows which have truish value in the mask.
|
||||
Maks length must equal annoMatrix.nObs (row count).
|
||||
*/
|
||||
return isubset(annoMatrix, _maskToList(obsMask));
|
||||
}
|
||||
|
||||
export function isubset(annoMatrix, obsOffsets) {
|
||||
/*
|
||||
Subset annomatrix to contain the positions contained in the obsOffsets array
|
||||
|
||||
Example:
|
||||
|
||||
isubset(annoMatrix, [0, 1]) -> annoMatrix with only the first two rows
|
||||
*/
|
||||
const obsIndex = annoMatrix.rowIndex.isubset(obsOffsets);
|
||||
return new AnnoMatrixRowSubsetView(annoMatrix, obsIndex);
|
||||
}
|
||||
|
||||
export function subset(annoMatrix, obsLabels) {
|
||||
/*
|
||||
subset based on labels
|
||||
*/
|
||||
const obsIndex = annoMatrix.rowIndex.subset(obsLabels);
|
||||
return new AnnoMatrixRowSubsetView(annoMatrix, obsIndex);
|
||||
}
|
||||
|
||||
export function clip(annoMatrix, qmin, qmax) {
|
||||
/*
|
||||
Create a view that clips all continuous data to the [min, max] range.
|
||||
The matrix shape does not change, but the continuous values outside the
|
||||
specified range will become a NaN.
|
||||
*/
|
||||
return new AnnoMatrixClipView(annoMatrix, qmin, qmax);
|
||||
}
|
||||
|
||||
/*
|
||||
Private utility functions below
|
||||
*/
|
||||
|
||||
function _maskToList(mask) {
|
||||
/* convert masks to lists - method wastes space, but is fast */
|
||||
if (!mask) {
|
||||
return null;
|
||||
}
|
||||
const list = new Int32Array(mask.length);
|
||||
let elems = 0;
|
||||
for (let i = 0, l = mask.length; i < l; i += 1) {
|
||||
if (mask[i]) {
|
||||
list[elems] = i;
|
||||
elems += 1;
|
||||
}
|
||||
}
|
||||
return new Int32Array(list.buffer, 0, elems);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/* eslint-disable max-classes-per-file -- Classes are interrelated*/
|
||||
|
||||
/*
|
||||
Views on the annomatrix. all API here is defined in viewCreators.js and annoMatrix.js.
|
||||
*/
|
||||
import clip from "../util/clip";
|
||||
import AnnoMatrix from "./annoMatrix";
|
||||
import { _whereCacheCreate } from "./whereCache";
|
||||
import { _isContinuousType, _getColumnSchema } from "./schema";
|
||||
|
||||
class AnnoMatrixView extends AnnoMatrix {
|
||||
constructor(viewOf, rowIndex = null) {
|
||||
const nObs = rowIndex ? rowIndex.size() : viewOf.nObs;
|
||||
super(viewOf.schema, nObs, viewOf.nVar, rowIndex || viewOf.rowIndex);
|
||||
this.viewOf = viewOf;
|
||||
this.isView = true;
|
||||
}
|
||||
|
||||
addObsAnnoCategory(col, category) {
|
||||
const o = this._clone();
|
||||
o.viewOf = this.viewOf.addObsAnnoCategory(col, category);
|
||||
o.schema = o.viewOf.schema;
|
||||
return o;
|
||||
}
|
||||
|
||||
async removeObsAnnoCategory(col, category, unassignedCategory) {
|
||||
const o = this._clone();
|
||||
o.viewOf = await this.viewOf.removeObsAnnoCategory(
|
||||
col,
|
||||
category,
|
||||
unassignedCategory
|
||||
);
|
||||
o.schema = o.viewOf.schema;
|
||||
return o;
|
||||
}
|
||||
|
||||
dropObsColumn(col) {
|
||||
const o = this._clone();
|
||||
o.viewOf = this.viewOf.dropObsColumn(col);
|
||||
o._cache.obs = this._cache.obs.dropCol(col);
|
||||
o.schema = o.viewOf.schema;
|
||||
return o;
|
||||
}
|
||||
|
||||
addObsColumn(colSchema, Ctor, value) {
|
||||
const o = this._clone();
|
||||
o.viewOf = this.viewOf.addObsColumn(colSchema, Ctor, value);
|
||||
o.schema = o.viewOf.schema;
|
||||
return o;
|
||||
}
|
||||
|
||||
renameObsColumn(oldCol, newCol) {
|
||||
const o = this._clone();
|
||||
o.viewOf = this.viewOf.renameObsColumn(oldCol, newCol);
|
||||
o.schema = o.viewOf.schema;
|
||||
return o;
|
||||
}
|
||||
|
||||
async setObsColumnValues(col, rowLabels, value) {
|
||||
const o = this._clone();
|
||||
o.viewOf = await this.viewOf.setObsColumnValues(col, rowLabels, value);
|
||||
o._cache.obs = this._cache.obs.dropCol(col);
|
||||
o.schema = o.viewOf.schema;
|
||||
return o;
|
||||
}
|
||||
|
||||
async resetObsColumnValues(col, oldValue, newValue) {
|
||||
const o = this._clone();
|
||||
o.viewOf = await this.viewOf.resetObsColumnValues(col, oldValue, newValue);
|
||||
o._cache.obs = this._cache.obs.dropCol(col);
|
||||
o.schema = o.viewOf.schema;
|
||||
return o;
|
||||
}
|
||||
}
|
||||
|
||||
class AnnoMatrixMapView extends AnnoMatrixView {
|
||||
/*
|
||||
A view which knows how to transform its data.
|
||||
*/
|
||||
constructor(viewOf, mapFn) {
|
||||
super(viewOf);
|
||||
this.mapFn = mapFn;
|
||||
}
|
||||
|
||||
async _doLoad(field, query) {
|
||||
const df = await this.viewOf._fetch(field, query);
|
||||
const dfMapped = df.mapColumns((colData, colIdx) => {
|
||||
const colLabel = df.colIndex.getLabel(colIdx);
|
||||
const colSchema = _getColumnSchema(this.schema, field, colLabel);
|
||||
return this.mapFn(field, colLabel, colSchema, colData, df);
|
||||
});
|
||||
const whereCacheUpdate = _whereCacheCreate(
|
||||
field,
|
||||
query,
|
||||
dfMapped.colIndex.labels()
|
||||
);
|
||||
return [whereCacheUpdate, dfMapped];
|
||||
}
|
||||
}
|
||||
|
||||
export class AnnoMatrixClipView extends AnnoMatrixMapView {
|
||||
/*
|
||||
A view which is a clipped transformation of its parent
|
||||
*/
|
||||
constructor(viewOf, qmin, qmax) {
|
||||
super(viewOf, (field, colLabel, colSchema, colData, df) =>
|
||||
_clipAnnoMatrix(field, colLabel, colSchema, colData, df, qmin, qmax)
|
||||
);
|
||||
this.isClipped = true;
|
||||
this.clipRange = [qmin, qmax];
|
||||
Object.seal(this);
|
||||
}
|
||||
}
|
||||
|
||||
export class AnnoMatrixRowSubsetView extends AnnoMatrixView {
|
||||
/*
|
||||
A view which is a subset of total rows.
|
||||
*/
|
||||
constructor(viewOf, rowIndex) {
|
||||
super(viewOf, rowIndex);
|
||||
Object.seal(this);
|
||||
}
|
||||
|
||||
async _doLoad(field, query) {
|
||||
const df = await this.viewOf._fetch(field, query);
|
||||
|
||||
// don't try to row-subset the var dimension.
|
||||
if (field === "var") {
|
||||
return [null, df];
|
||||
}
|
||||
|
||||
const dfSubset = df.subset(null, null, this.rowIndex);
|
||||
const whereCacheUpdate = _whereCacheCreate(
|
||||
field,
|
||||
query,
|
||||
dfSubset.colIndex.labels()
|
||||
);
|
||||
return [whereCacheUpdate, dfSubset];
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Utility functions below
|
||||
*/
|
||||
|
||||
function _clipAnnoMatrix(field, colLabel, colSchema, colData, df, qmin, qmax) {
|
||||
/* only clip obs and var scalar columns */
|
||||
if (field !== "obs" && field !== "X") return colData;
|
||||
if (!_isContinuousType(colSchema)) return colData;
|
||||
if (qmin < 0) qmin = 0;
|
||||
if (qmax > 1) qmax = 1;
|
||||
if (qmin === 0 && qmax === 1) return colData;
|
||||
|
||||
const quantiles = df.col(colLabel).summarize().percentiles;
|
||||
const lower = quantiles[100 * qmin];
|
||||
const upper = quantiles[100 * qmax];
|
||||
const clippedData = clip(colData.slice(), lower, upper, Number.NaN);
|
||||
return clippedData;
|
||||
}
|
||||
|
||||
/* eslint-enable max-classes-per-file -- enable*/
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
Private support functions.
|
||||
|
||||
Support for a "where" query, eg,
|
||||
|
||||
{ where: { field: "var", column: "gene", value: "FOXP2" }}
|
||||
|
||||
These evaluate to a given column label.
|
||||
|
||||
The "where cache" is a map that saves evaluated queries and points
|
||||
to the column label they resolve to.
|
||||
|
||||
Data structure, using X as the example field being queried, and var as
|
||||
the index.
|
||||
|
||||
{
|
||||
X: {
|
||||
var: Map(
|
||||
column_label_in_var => Map(value_in_var_column => [column_label_in_X, ...])
|
||||
)
|
||||
}
|
||||
}
|
||||
*/
|
||||
import { _getColumnDimensionNames } from "./schema";
|
||||
|
||||
export function _whereCacheGet(whereCache, schema, field, query) {
|
||||
/*
|
||||
query will either be an where query (object) or a column name (string).
|
||||
|
||||
Return array of column labels or undefined.
|
||||
*/
|
||||
|
||||
if (typeof query === "object") {
|
||||
const { field: queryField, column: queryColumn, value: queryValue } = query;
|
||||
|
||||
const columnMap = whereCache?.[field]?.[queryField];
|
||||
if (columnMap === undefined) return [undefined];
|
||||
|
||||
const valueMap = columnMap.get(queryColumn);
|
||||
if (valueMap === undefined) return [undefined];
|
||||
|
||||
const columnLabels = valueMap.get(queryValue);
|
||||
return columnLabels === undefined ? [undefined] : columnLabels;
|
||||
}
|
||||
|
||||
const colDims = _getColumnDimensionNames(schema, field, query);
|
||||
return colDims === undefined ? [undefined] : colDims;
|
||||
}
|
||||
|
||||
export function _whereCacheCreate(field, query, columnLabels) {
|
||||
/*
|
||||
Create a new whereCache
|
||||
*/
|
||||
if (typeof query !== "object") return null;
|
||||
|
||||
const { field: queryField, column: queryColumn, value: queryValue } = query;
|
||||
const whereCache = {
|
||||
[field]: {
|
||||
[queryField]: new Map([
|
||||
[queryColumn, new Map([[queryValue, columnLabels]])],
|
||||
]),
|
||||
},
|
||||
};
|
||||
return whereCache;
|
||||
}
|
||||
|
||||
function __whereCacheMerge(dst, src) {
|
||||
/*
|
||||
merge src into dst (modifies dst)
|
||||
*/
|
||||
if (!dst) dst = {};
|
||||
if (!src || typeof src !== "object") return dst;
|
||||
Object.entries(src).forEach(([field, query]) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(dst, field)) dst[field] = {};
|
||||
Object.entries(query).forEach(([queryField, columnMap]) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(dst[field], queryField))
|
||||
dst[field][queryField] = new Map();
|
||||
columnMap.forEach((valueMap, queryColumn) => {
|
||||
if (!dst[field][queryField].has(queryColumn))
|
||||
dst[field][queryField].set(queryColumn, new Map());
|
||||
valueMap.forEach((columnLabels, queryValue) => {
|
||||
dst[field][queryField].get(queryColumn).set(queryValue, columnLabels);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return dst;
|
||||
}
|
||||
|
||||
export function _whereCacheMerge(...caches) {
|
||||
return caches.reduce((dst, src) => __whereCacheMerge(dst, src), {});
|
||||
}
|
||||
@@ -64,10 +64,10 @@ class App extends React.Component {
|
||||
left: window.innerWidth / 2 - 50,
|
||||
}}
|
||||
>
|
||||
error loading
|
||||
error loading cellxgene
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? null : (
|
||||
{loading || error ? null : (
|
||||
<Layout>
|
||||
<LeftSideBar />
|
||||
{(viewportRef) => (
|
||||
|
||||
@@ -11,13 +11,8 @@ import {
|
||||
} from "@blueprintjs/core";
|
||||
|
||||
@connect((state) => ({
|
||||
universe: state.universe,
|
||||
idhash: state.config?.parameters?.["annotations-user-data-idhash"] ?? null,
|
||||
annotations: state.annotations,
|
||||
obsAnnotations: state.universe.obsAnnotations,
|
||||
saveInProgress: state.autosave?.saveInProgress ?? false,
|
||||
lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations,
|
||||
error: state.autosave?.error,
|
||||
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
|
||||
}))
|
||||
class FilenameDialog extends React.Component {
|
||||
|
||||
@@ -4,14 +4,12 @@ import actions from "../../actions";
|
||||
import FilenameDialog from "./filenameDialog";
|
||||
|
||||
@connect((state) => ({
|
||||
universe: state.universe,
|
||||
annotations: state.annotations,
|
||||
obsAnnotations: state.universe.obsAnnotations,
|
||||
saveInProgress: state.autosave?.saveInProgress ?? false,
|
||||
lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations,
|
||||
error: state.autosave?.error,
|
||||
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
|
||||
initialDataLoadComplete: state.autosave?.initialDataLoadComplete,
|
||||
annoMatrix: state.annoMatrix,
|
||||
lastSavedAnnoMatrix: state.autosave?.lastSavedAnnoMatrix,
|
||||
}))
|
||||
class Autosave extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -42,16 +40,14 @@ class Autosave extends React.Component {
|
||||
tick = () => {
|
||||
const { dispatch, saveInProgress } = this.props;
|
||||
if (this.needToSave() && !saveInProgress) {
|
||||
dispatch(actions.saveObsAnnotations());
|
||||
dispatch(actions.saveObsAnnotationsAction());
|
||||
}
|
||||
};
|
||||
|
||||
needToSave = () => {
|
||||
/* return true if we need to save, false if we don't */
|
||||
const { obsAnnotations, lastSavedObsAnnotations } = this.props;
|
||||
return (
|
||||
lastSavedObsAnnotations && obsAnnotations !== lastSavedObsAnnotations
|
||||
);
|
||||
const { annoMatrix, lastSavedAnnoMatrix } = this.props;
|
||||
return actions.needToSaveObsAnnotations(annoMatrix, lastSavedAnnoMatrix);
|
||||
};
|
||||
|
||||
statusMessage() {
|
||||
@@ -66,9 +62,13 @@ class Autosave extends React.Component {
|
||||
const {
|
||||
writableCategoriesEnabled,
|
||||
saveInProgress,
|
||||
initialDataLoadComplete,
|
||||
lastSavedAnnoMatrix,
|
||||
} = this.props;
|
||||
return writableCategoriesEnabled ? (
|
||||
const initialDataLoadComplete = lastSavedAnnoMatrix;
|
||||
|
||||
if (!writableCategoriesEnabled) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
id="autosave"
|
||||
data-testclass={
|
||||
@@ -89,7 +89,7 @@ class Autosave extends React.Component {
|
||||
{this.statusMessage()}
|
||||
<FilenameDialog />
|
||||
</div>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,12 +3,13 @@ import { connect } from "react-redux";
|
||||
import AnnoDialog from "../annoDialog";
|
||||
import LabelInput from "../labelInput";
|
||||
import { labelPrompt, isLabelErroneous } from "../labelUtil";
|
||||
import actions from "../../../actions";
|
||||
|
||||
@connect((state) => ({
|
||||
annotations: state.annotations,
|
||||
universe: state.universe,
|
||||
schema: state.annoMatrix?.schema,
|
||||
ontology: state.ontology,
|
||||
crossfilter: state.crossfilter,
|
||||
obsCrossfilter: state.obsCrossfilter,
|
||||
}))
|
||||
class Category extends React.PureComponent {
|
||||
constructor(props) {
|
||||
@@ -34,12 +35,13 @@ class Category extends React.PureComponent {
|
||||
const { newLabelText } = this.state;
|
||||
|
||||
this.disableAddNewLabelMode();
|
||||
dispatch({
|
||||
type: "annotation: add new label to category",
|
||||
metadataField,
|
||||
newLabelText,
|
||||
assignSelectedCells: false,
|
||||
});
|
||||
dispatch(
|
||||
actions.annotationCreateLabelInCategory(
|
||||
metadataField,
|
||||
newLabelText,
|
||||
false
|
||||
)
|
||||
);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
@@ -48,18 +50,15 @@ class Category extends React.PureComponent {
|
||||
const { newLabelText } = this.state;
|
||||
|
||||
this.disableAddNewLabelMode();
|
||||
dispatch({
|
||||
type: "annotation: add new label to category",
|
||||
metadataField,
|
||||
newLabelText,
|
||||
assignSelectedCells: true,
|
||||
});
|
||||
dispatch(
|
||||
actions.annotationCreateLabelInCategory(metadataField, newLabelText, true)
|
||||
);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
labelNameError = (name) => {
|
||||
const { metadataField, ontology, universe } = this.props;
|
||||
return isLabelErroneous(name, metadataField, ontology, universe.schema);
|
||||
const { metadataField, ontology, schema } = this.props;
|
||||
return isLabelErroneous(name, metadataField, ontology, schema);
|
||||
};
|
||||
|
||||
instruction = (label) => {
|
||||
@@ -72,7 +71,7 @@ class Category extends React.PureComponent {
|
||||
|
||||
render() {
|
||||
const { newLabelText } = this.state;
|
||||
const { metadataField, annotations, ontology, crossfilter } = this.props;
|
||||
const { metadataField, annotations, ontology, obsCrossfilter } = this.props;
|
||||
const ontologyEnabled = ontology?.enabled ?? false;
|
||||
|
||||
return (
|
||||
@@ -90,7 +89,7 @@ class Category extends React.PureComponent {
|
||||
instruction={this.instruction(newLabelText)}
|
||||
cancelTooltipContent="Close this dialog without adding a label."
|
||||
primaryButtonText="Add label"
|
||||
secondaryButtonText={`Add label & assign ${crossfilter.countSelected()} selected cells`}
|
||||
secondaryButtonText={`Add label & assign ${obsCrossfilter.countSelected()} selected cells`}
|
||||
handleSecondaryButtonSubmit={this.addLabelAndAssignCells}
|
||||
text={newLabelText}
|
||||
validationError={this.labelNameError(newLabelText)}
|
||||
|
||||
@@ -5,11 +5,11 @@ import LabelInput from "../labelInput";
|
||||
import { labelPrompt } from "../labelUtil";
|
||||
|
||||
import { AnnotationsHelpers } from "../../../util/stateManager";
|
||||
import actions from "../../../actions";
|
||||
|
||||
@connect((state) => ({
|
||||
annotations: state.annotations,
|
||||
universe: state.universe,
|
||||
schema: state.world?.schema,
|
||||
schema: state.annoMatrix?.schema,
|
||||
ontology: state.ontology,
|
||||
}))
|
||||
class AnnoDialogEditCategoryName extends React.PureComponent {
|
||||
@@ -42,7 +42,9 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
|
||||
test for uniqueness against *all* annotation names, not just the subset
|
||||
we render as categorical.
|
||||
*/
|
||||
const allCategoryNames = this.allCategoryNames();
|
||||
const { schema } = this.props;
|
||||
const allCategoryNames = schema.annotations.obs.columns.map((c) => c.name);
|
||||
|
||||
if (
|
||||
(allCategoryNames.indexOf(newCategoryText) > -1 &&
|
||||
newCategoryText !== metadataField) ||
|
||||
@@ -52,12 +54,11 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
|
||||
}
|
||||
|
||||
this.disableEditCategoryMode();
|
||||
dispatch({
|
||||
type: "annotation: category edited",
|
||||
metadataField,
|
||||
newCategoryText,
|
||||
data: newCategoryText,
|
||||
});
|
||||
|
||||
if (metadataField !== newCategoryText)
|
||||
dispatch(
|
||||
actions.annotationRenameCategoryAction(metadataField, newCategoryText)
|
||||
);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
@@ -76,7 +77,9 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
|
||||
test for uniqueness against *all* annotation names, not just the subset
|
||||
we render as categorical.
|
||||
*/
|
||||
const allCategoryNames = this.allCategoryNames();
|
||||
const { schema } = this.props;
|
||||
const allCategoryNames = schema.annotations.obs.columns.map((c) => c.name);
|
||||
|
||||
const categoryNameAlreadyExists = allCategoryNames.indexOf(name) > -1;
|
||||
const sameName = name === metadataField;
|
||||
if (categoryNameAlreadyExists && !sameName) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@blueprintjs/core";
|
||||
|
||||
import * as globals from "../../../globals";
|
||||
import actions from "../../../actions";
|
||||
|
||||
@connect((state) => ({
|
||||
annotations: state.annotations,
|
||||
@@ -41,10 +42,7 @@ class AnnoMenuCategory extends React.PureComponent {
|
||||
|
||||
handleDeleteCategory = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
type: "annotation: delete category",
|
||||
metadataField,
|
||||
});
|
||||
dispatch(actions.annotationDeleteCategoryAction(metadataField));
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@@ -4,14 +4,17 @@ import { Flipper, Flipped } from "react-flip-toolkit";
|
||||
import * as globals from "../../../globals";
|
||||
import Value from "../value";
|
||||
|
||||
class Category extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
class Category extends React.PureComponent {
|
||||
renderCategoryItems(optTuples) {
|
||||
const { metadataField, isUserAnno, categorySummary } = this.props;
|
||||
const {
|
||||
metadataField,
|
||||
isUserAnno,
|
||||
categoryData,
|
||||
categorySummary,
|
||||
colorAccessor,
|
||||
colorData,
|
||||
colorTable,
|
||||
} = this.props;
|
||||
|
||||
return optTuples.map((tuple, i) => {
|
||||
return (
|
||||
@@ -25,7 +28,11 @@ class Category extends React.Component {
|
||||
categoryIndex={tuple[1]}
|
||||
i={i}
|
||||
flippedProps={flippedProps}
|
||||
categoryData={categoryData}
|
||||
categorySummary={categorySummary}
|
||||
colorAccessor={colorAccessor}
|
||||
colorData={colorData}
|
||||
colorTable={colorTable}
|
||||
/>
|
||||
)}
|
||||
</Flipped>
|
||||
|
||||
@@ -1,88 +1,101 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import { connect, shallowEqual } from "react-redux";
|
||||
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
|
||||
import { AnchorButton, Button, Tooltip } from "@blueprintjs/core";
|
||||
import Async from "react-async";
|
||||
import memoize from "memoize-one";
|
||||
|
||||
import CategoryFlipperLayout from "./categoryFlipperLayout";
|
||||
import AnnoMenu from "./annoMenuCategory";
|
||||
import AnnoDialogEditCategoryName from "./annoDialogEditCategoryName";
|
||||
import AnnoDialogAddLabel from "./annoDialogAddLabel";
|
||||
import Truncate from "../../util/truncate";
|
||||
import { CategoryCrossfilterContext } from "../categoryContext";
|
||||
|
||||
import * as globals from "../../../globals";
|
||||
import { createCategorySummary as _createCategorySummary } from "../../../util/stateManager/controlsHelpers";
|
||||
import { createCategorySummaryFromDfCol } from "../../../util/stateManager/controlsHelpers";
|
||||
import {
|
||||
createColorTable,
|
||||
createColorQuery,
|
||||
} from "../../../util/stateManager/colorHelpers";
|
||||
import actions from "../../../actions";
|
||||
|
||||
const LABEL_WIDTH = globals.leftSidebarWidth - 100;
|
||||
const ANNO_BUTTON_WIDTH = 50;
|
||||
const LABEL_WIDTH_ANNO = LABEL_WIDTH - ANNO_BUTTON_WIDTH;
|
||||
|
||||
@connect((state, ownProps) => {
|
||||
const schema = state.annoMatrix?.schema;
|
||||
const { metadataField } = ownProps;
|
||||
const isUserAnno = schema?.annotations?.obsByName[metadataField]?.writable;
|
||||
const categoricalSelection = state.categoricalSelection?.[metadataField];
|
||||
return {
|
||||
isColorAccessor: state.colors.colorAccessor === metadataField,
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
colors: state.colors,
|
||||
categoricalSelection,
|
||||
annotations: state.annotations,
|
||||
universe: state.universe,
|
||||
world: state.world,
|
||||
schema: state.world?.schema,
|
||||
annoMatrix: state.annoMatrix,
|
||||
schema,
|
||||
crossfilter: state.obsCrossfilter,
|
||||
isUserAnno,
|
||||
};
|
||||
})
|
||||
class Category extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isChecked: true,
|
||||
categorySummary: this.createCategorySummary(),
|
||||
};
|
||||
class Category extends React.PureComponent {
|
||||
static getSelectionState(
|
||||
categoricalSelection,
|
||||
metadataField,
|
||||
categorySummary
|
||||
) {
|
||||
// total number of categories in this dimension
|
||||
const totalCatCount = categorySummary.numCategoryValues;
|
||||
// number of selected options in this category
|
||||
const selectedCatCount = categorySummary.categoryValues.reduce(
|
||||
(res, label) => (categoricalSelection.get(label) ?? true ? res + 1 : res),
|
||||
0
|
||||
);
|
||||
return selectedCatCount === totalCatCount
|
||||
? "all"
|
||||
: selectedCatCount === 0
|
||||
? "none"
|
||||
: "some";
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { categoricalSelection, metadataField, world } = this.props;
|
||||
let { categorySummary } = this.state;
|
||||
|
||||
if (
|
||||
world !== prevProps.world ||
|
||||
metadataField !== prevProps.metadataField ||
|
||||
!categorySummary
|
||||
) {
|
||||
const newCategorySummary = this.createCategorySummary();
|
||||
if (categorySummary !== newCategorySummary) {
|
||||
categorySummary = newCategorySummary;
|
||||
/* eslint-disable-next-line react/no-did-update-set-state -- Contained in if statement to prevent infinite looping */
|
||||
this.setState({ categorySummary });
|
||||
}
|
||||
}
|
||||
|
||||
const cat = categoricalSelection?.[metadataField];
|
||||
if (
|
||||
categoricalSelection !== prevProps.categoricalSelection &&
|
||||
!!cat &&
|
||||
!!this.checkbox
|
||||
) {
|
||||
// total number of categories in this dimension
|
||||
const totalCatCount = categorySummary.numCategoryValues;
|
||||
// number of selected options in this category
|
||||
const selectedCatCount = categorySummary.categoryValues.reduce(
|
||||
(res, label) => (cat.get(label) ?? true ? res + 1 : res),
|
||||
0
|
||||
);
|
||||
/* eslint-disable react/no-did-update-set-state -- Contained in if statement to prevent infinite looping */
|
||||
if (selectedCatCount === totalCatCount) {
|
||||
/* everything is on, so not indeterminate */
|
||||
this.checkbox.indeterminate = false;
|
||||
this.setState({ isChecked: true });
|
||||
} else if (selectedCatCount === 0) {
|
||||
/* nothing is on, so no */
|
||||
this.checkbox.indeterminate = false;
|
||||
this.setState({ isChecked: false });
|
||||
} else if (selectedCatCount < totalCatCount) {
|
||||
/* to be explicit... */
|
||||
this.checkbox.indeterminate = true;
|
||||
this.setState({ isChecked: false });
|
||||
}
|
||||
/* eslint-enable react/no-did-update-set-state -- re-enabling*/
|
||||
}
|
||||
static watchAsync(props, prevProps) {
|
||||
return !shallowEqual(props.watchProps, prevProps.watchProps);
|
||||
}
|
||||
|
||||
static async fetchData(annoMatrix, metadataField, colors) {
|
||||
/*
|
||||
fetch our data and the color-by data if appropriate, and then build a summary
|
||||
of our category and a color table for the color-by annotation.
|
||||
*/
|
||||
const { schema } = annoMatrix;
|
||||
const { colorAccessor, colorMode } = colors;
|
||||
let colorDataPromise = Promise.resolve(null);
|
||||
if (colorAccessor) {
|
||||
const query = createColorQuery(colorMode, colorAccessor, schema);
|
||||
if (query) colorDataPromise = annoMatrix.fetch(...query);
|
||||
}
|
||||
const [categoryData, colorData] = await Promise.all([
|
||||
annoMatrix.fetch("obs", metadataField),
|
||||
colorDataPromise,
|
||||
]);
|
||||
|
||||
// our data
|
||||
const column = categoryData.icol(0);
|
||||
const colSchema = schema.annotations.obsByName[metadataField];
|
||||
const categorySummary = createCategorySummaryFromDfCol(column, colSchema);
|
||||
return [categoryData, categorySummary, colorData];
|
||||
}
|
||||
|
||||
getSelectionState = memoize((categorySummary) => {
|
||||
const { categoricalSelection, metadataField } = this.props;
|
||||
return Category.getSelectionState(
|
||||
categoricalSelection,
|
||||
metadataField,
|
||||
categorySummary
|
||||
);
|
||||
});
|
||||
|
||||
handleColorChange = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch({
|
||||
@@ -101,138 +114,252 @@ class Category extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
createCategorySummary() {
|
||||
const { world, metadataField } = this.props;
|
||||
if (!world || !metadataField || !world.obsAnnotations.hasCol(metadataField))
|
||||
return null;
|
||||
return _createCategorySummary(world, metadataField);
|
||||
}
|
||||
|
||||
toggleNone() {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
const { categorySummary } = this.state;
|
||||
dispatch({
|
||||
type: "categorical metadata filter none of these",
|
||||
metadataField,
|
||||
labels: categorySummary.categoryValues,
|
||||
});
|
||||
this.setState({ isChecked: false });
|
||||
}
|
||||
|
||||
toggleAll() {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
const { categorySummary } = this.state;
|
||||
dispatch({
|
||||
type: "categorical metadata filter all of these",
|
||||
metadataField,
|
||||
labels: categorySummary.categoryValues,
|
||||
});
|
||||
this.setState({ isChecked: true });
|
||||
}
|
||||
|
||||
handleToggleAllClick() {
|
||||
const { isChecked } = this.state;
|
||||
if (isChecked) {
|
||||
this.toggleNone();
|
||||
} else {
|
||||
this.toggleAll();
|
||||
handleCategoryKeyPress = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
this.handleCategoryClick();
|
||||
}
|
||||
};
|
||||
|
||||
handleToggleAllClick = (categorySummary) => {
|
||||
const isChecked = this.getSelectionState(categorySummary);
|
||||
if (isChecked === "all") {
|
||||
this.toggleNone(categorySummary);
|
||||
} else {
|
||||
this.toggleAll(categorySummary);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAsyncProps = async (props) => {
|
||||
const { annoMatrix, metadataField, colors } = props.watchProps;
|
||||
const { crossfilter } = this.props;
|
||||
|
||||
const [categoryData, categorySummary, colorData] = await Category.fetchData(
|
||||
annoMatrix,
|
||||
metadataField,
|
||||
colors
|
||||
);
|
||||
|
||||
return {
|
||||
categoryData,
|
||||
categorySummary,
|
||||
colorData,
|
||||
crossfilter,
|
||||
...this.updateColorTable(colorData),
|
||||
handleCategoryToggleAllClick: () =>
|
||||
this.handleToggleAllClick(categorySummary),
|
||||
};
|
||||
};
|
||||
|
||||
updateColorTable(colorData) {
|
||||
// color table, which may be null
|
||||
const { schema, colors, metadataField } = this.props;
|
||||
const { colorAccessor, userColors, colorMode } = colors;
|
||||
return {
|
||||
isColorAccessor: colorAccessor === metadataField,
|
||||
colorAccessor,
|
||||
colorMode,
|
||||
colorTable: createColorTable(
|
||||
colorMode,
|
||||
colorAccessor,
|
||||
colorData,
|
||||
schema,
|
||||
userColors
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
renderIsStillLoading() {
|
||||
/*
|
||||
We are still loading this category, so render a "busy" signal.
|
||||
*/
|
||||
const { metadataField } = this.props;
|
||||
toggleNone(categorySummary) {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch(
|
||||
actions.selectCategoricalAllMetadataAction(
|
||||
"categorical metadata filter none of these",
|
||||
metadataField,
|
||||
categorySummary.categoryValues,
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
toggleAll(categorySummary) {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
dispatch(
|
||||
actions.selectCategoricalAllMetadataAction(
|
||||
"categorical metadata filter all of these",
|
||||
metadataField,
|
||||
categorySummary.categoryValues,
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
metadataField,
|
||||
isExpanded,
|
||||
categoricalSelection,
|
||||
crossfilter,
|
||||
colors,
|
||||
annoMatrix,
|
||||
isUserAnno,
|
||||
} = this.props;
|
||||
|
||||
const checkboxID = `category-select-${metadataField}`;
|
||||
|
||||
return (
|
||||
<CategoryCrossfilterContext.Provider value={crossfilter}>
|
||||
<Async
|
||||
watchFn={Category.watchAsync}
|
||||
promiseFn={this.fetchAsyncProps}
|
||||
watchProps={{
|
||||
metadataField,
|
||||
annoMatrix,
|
||||
categoricalSelection,
|
||||
colors,
|
||||
}}
|
||||
>
|
||||
<Async.Pending initial>
|
||||
<StillLoading
|
||||
metadataField={metadataField}
|
||||
checkboxID={checkboxID}
|
||||
/>
|
||||
</Async.Pending>
|
||||
<Async.Rejected>
|
||||
{(error) => (
|
||||
<ErrorLoading metadataField={metadataField} error={error} />
|
||||
)}
|
||||
</Async.Rejected>
|
||||
<Async.Fulfilled>
|
||||
{(asyncProps) => {
|
||||
const {
|
||||
colorAccessor,
|
||||
colorTable,
|
||||
colorData,
|
||||
categoryData,
|
||||
categorySummary,
|
||||
isColorAccessor,
|
||||
handleCategoryToggleAllClick,
|
||||
} = asyncProps;
|
||||
return (
|
||||
<CategoryRender
|
||||
metadataField={metadataField}
|
||||
checkboxID={checkboxID}
|
||||
isUserAnno={isUserAnno}
|
||||
isTruncated={!!categorySummary?.isTruncated}
|
||||
isExpanded={isExpanded}
|
||||
isColorAccessor={isColorAccessor}
|
||||
selectionState={this.getSelectionState(categorySummary)}
|
||||
categoryData={categoryData}
|
||||
categorySummary={categorySummary}
|
||||
colorAccessor={colorAccessor}
|
||||
colorData={colorData}
|
||||
colorTable={colorTable}
|
||||
onColorChangeClick={this.handleColorChange}
|
||||
onCategoryToggleAllClick={handleCategoryToggleAllClick}
|
||||
onCategoryMenuClick={this.handleCategoryClick}
|
||||
onCategoryMenuKeyPress={this.handleCategoryKeyPress}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Async.Fulfilled>
|
||||
</Async>
|
||||
</CategoryCrossfilterContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Category;
|
||||
|
||||
const StillLoading = ({ metadataField, checkboxID }) => {
|
||||
/*
|
||||
We are still loading this category, so render a "busy" signal.
|
||||
*/
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: globals.maxControlsWidth,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: globals.maxControlsWidth,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<label htmlFor={checkboxID} className="bp3-control bp3-checkbox">
|
||||
<input disabled id={checkboxID} checked type="checkbox" />
|
||||
<span className="bp3-control-indicator" />
|
||||
</label>
|
||||
<Truncate>
|
||||
<span
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "inline-block",
|
||||
width: LABEL_WIDTH,
|
||||
}}
|
||||
>
|
||||
{metadataField}
|
||||
</span>
|
||||
</Truncate>
|
||||
</div>
|
||||
<div>
|
||||
<Button minimal loading intent="primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isChecked, categorySummary } = this.state;
|
||||
const { metadataField, isColorAccessor, isExpanded, schema } = this.props;
|
||||
|
||||
const isStillLoading = !categorySummary;
|
||||
if (isStillLoading) {
|
||||
return this.renderIsStillLoading();
|
||||
}
|
||||
|
||||
const checkboxID = `category-select-${metadataField}`;
|
||||
|
||||
const isUserAnno = !!schema?.annotations?.obsByName[metadataField]
|
||||
?.writable;
|
||||
const isTruncated = !!categorySummary?.isTruncated;
|
||||
|
||||
if (
|
||||
!isUserAnno &&
|
||||
schema?.annotations?.obsByName[metadataField]?.categories?.length === 1
|
||||
) {
|
||||
return (
|
||||
<div style={{ marginBottom: 10, marginTop: 4 }}>
|
||||
<label htmlFor={checkboxID} className="bp3-control bp3-checkbox">
|
||||
<input disabled id={checkboxID} checked type="checkbox" />
|
||||
<span className="bp3-control-indicator" />
|
||||
</label>
|
||||
<Truncate>
|
||||
<span style={{ maxWidth: 150, fontWeight: 700 }}>
|
||||
<span
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "inline-block",
|
||||
width: LABEL_WIDTH,
|
||||
}}
|
||||
>
|
||||
{metadataField}
|
||||
</span>
|
||||
</Truncate>
|
||||
<Truncate>
|
||||
<span style={{ maxWidth: 150 }}>
|
||||
{`: ${schema.annotations.obsByName[metadataField].categories[0]}`}
|
||||
</span>
|
||||
</Truncate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div>
|
||||
<Button minimal loading intent="primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorLoading = ({ metadataField, error }) => {
|
||||
console.error(error); // log error to console as it is unexpected.
|
||||
return (
|
||||
<div style={{ marginBottom: 10, marginTop: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "inline-block",
|
||||
width: LABEL_WIDTH,
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{`Failure loading ${metadataField}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CategoryHeader = React.memo(
|
||||
({
|
||||
metadataField,
|
||||
checkboxID,
|
||||
isUserAnno,
|
||||
isTruncated,
|
||||
isColorAccessor,
|
||||
isExpanded,
|
||||
selectionState,
|
||||
onColorChangeClick,
|
||||
onCategoryMenuClick,
|
||||
onCategoryMenuKeyPress,
|
||||
onCategoryToggleAllClick,
|
||||
}) => {
|
||||
/*
|
||||
Render category name and controls (eg, color-by button).
|
||||
*/
|
||||
const checkboxRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
checkboxRef.current.indeterminate = selectionState === "some";
|
||||
}, [checkboxRef.current, selectionState]);
|
||||
|
||||
return (
|
||||
<CategoryFlipperLayout
|
||||
metadataField={metadataField}
|
||||
isExpanded={isExpanded}
|
||||
isUserAnno={isUserAnno}
|
||||
categorySummary={categorySummary}
|
||||
>
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -245,12 +372,9 @@ class Category extends React.Component {
|
||||
id={checkboxID}
|
||||
data-testclass="category-select"
|
||||
data-testid={`${metadataField}:category-select`}
|
||||
onChange={this.handleToggleAllClick.bind(this)}
|
||||
ref={(el) => {
|
||||
this.checkbox = el;
|
||||
return el;
|
||||
}}
|
||||
checked={isChecked}
|
||||
onChange={onCategoryToggleAllClick}
|
||||
ref={checkboxRef}
|
||||
checked={selectionState === "all"}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="bp3-control-indicator" />
|
||||
@@ -260,15 +384,11 @@ class Category extends React.Component {
|
||||
tabIndex="0"
|
||||
data-testclass="category-expand"
|
||||
data-testid={`${metadataField}:category-expand`}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
this.handleCategoryClick();
|
||||
}
|
||||
}}
|
||||
onKeyPress={onCategoryMenuKeyPress}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={this.handleCategoryClick}
|
||||
onClick={onCategoryMenuClick}
|
||||
>
|
||||
<Truncate>
|
||||
<span
|
||||
@@ -317,7 +437,7 @@ class Category extends React.Component {
|
||||
<AnchorButton
|
||||
data-testclass="colorby"
|
||||
data-testid={`colorby-${metadataField}`}
|
||||
onClick={this.handleColorChange}
|
||||
onClick={onColorChangeClick}
|
||||
active={isColorAccessor}
|
||||
intent={isColorAccessor ? "primary" : "none"}
|
||||
disabled={isTruncated}
|
||||
@@ -325,9 +445,83 @@ class Category extends React.Component {
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const CategoryRender = React.memo(
|
||||
({
|
||||
metadataField,
|
||||
checkboxID,
|
||||
isUserAnno,
|
||||
isTruncated,
|
||||
isColorAccessor,
|
||||
isExpanded,
|
||||
selectionState,
|
||||
categoryData,
|
||||
categorySummary,
|
||||
colorAccessor,
|
||||
colorData,
|
||||
colorTable,
|
||||
onColorChangeClick,
|
||||
onCategoryMenuClick,
|
||||
onCategoryMenuKeyPress,
|
||||
onCategoryToggleAllClick,
|
||||
}) => {
|
||||
/*
|
||||
Render the core of the category, including checkboxes, controls, etc.
|
||||
*/
|
||||
const { numCategoryValues } = categorySummary;
|
||||
const isSingularValue = !isUserAnno && numCategoryValues === 1;
|
||||
|
||||
if (isSingularValue) {
|
||||
/*
|
||||
Entire category has a single value, special case.
|
||||
*/
|
||||
const theOneValue = categorySummary.categoryValues[0];
|
||||
return (
|
||||
<div style={{ marginBottom: 10, marginTop: 4 }}>
|
||||
<Truncate>
|
||||
<span style={{ maxWidth: 150, fontWeight: 700 }}>
|
||||
{metadataField}
|
||||
</span>
|
||||
</Truncate>
|
||||
<Truncate>
|
||||
<span style={{ maxWidth: 150 }}>{`: ${theOneValue}`}</span>
|
||||
</Truncate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
Otherwise, our normal multi-layout layout
|
||||
*/
|
||||
return (
|
||||
<CategoryFlipperLayout
|
||||
metadataField={metadataField}
|
||||
isExpanded={isExpanded}
|
||||
isUserAnno={isUserAnno}
|
||||
categoryData={categoryData}
|
||||
categorySummary={categorySummary}
|
||||
colorAccessor={colorAccessor}
|
||||
colorData={colorData}
|
||||
colorTable={colorTable}
|
||||
>
|
||||
<CategoryHeader
|
||||
metadataField={metadataField}
|
||||
checkboxID={checkboxID}
|
||||
isUserAnno={isUserAnno}
|
||||
isTruncated={isTruncated}
|
||||
isExpanded={isExpanded}
|
||||
isColorAccessor={isColorAccessor}
|
||||
selectionState={selectionState}
|
||||
onColorChangeClick={onColorChangeClick}
|
||||
onCategoryToggleAllClick={onCategoryToggleAllClick}
|
||||
onCategoryMenuClick={onCategoryMenuClick}
|
||||
onCategoryMenuKeyPress={onCategoryMenuKeyPress}
|
||||
/>
|
||||
</CategoryFlipperLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Category;
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
/*
|
||||
CategoryCrossfilterContext is used to pass a snapshot of the crossfilter
|
||||
matching the current category summary.
|
||||
*/
|
||||
export const CategoryCrossfilterContext = React.createContext(null);
|
||||
@@ -9,10 +9,11 @@ import AnnoDialog from "./annoDialog";
|
||||
import AnnoSelect from "./annoSelect";
|
||||
import LabelInput from "./labelInput";
|
||||
import { labelPrompt } from "./labelUtil";
|
||||
import actions from "../../actions";
|
||||
|
||||
@connect((state) => ({
|
||||
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
|
||||
schema: state.world?.schema,
|
||||
schema: state.annoMatrix?.schema,
|
||||
ontology: state.ontology,
|
||||
}))
|
||||
class Categories extends React.Component {
|
||||
@@ -29,11 +30,12 @@ class Categories extends React.Component {
|
||||
handleCreateUserAnno = (e) => {
|
||||
const { dispatch } = this.props;
|
||||
const { newCategoryText, categoryToDuplicate } = this.state;
|
||||
dispatch({
|
||||
type: "annotation: create category",
|
||||
data: newCategoryText,
|
||||
categoryToDuplicate,
|
||||
});
|
||||
dispatch(
|
||||
actions.annotationCreateCategoryAction(
|
||||
newCategoryText,
|
||||
categoryToDuplicate
|
||||
)
|
||||
);
|
||||
this.setState({
|
||||
createAnnoModeActive: false,
|
||||
categoryToDuplicate: null,
|
||||
|
||||
@@ -19,8 +19,10 @@ import Truncate from "../../util/truncate";
|
||||
|
||||
import { AnnotationsHelpers } from "../../../util/stateManager";
|
||||
import { labelPrompt, isLabelErroneous } from "../labelUtil";
|
||||
import actions from "../../../actions";
|
||||
import MiniHistogram from "../../miniHistogram";
|
||||
import MiniStackedBar from "../../miniStackedBar";
|
||||
import { CategoryCrossfilterContext } from "../categoryContext";
|
||||
|
||||
const VALUE_HEIGHT = 11;
|
||||
const CHART_WIDTH = 100;
|
||||
@@ -42,11 +44,7 @@ function _currentLabelAsString(ownProps) {
|
||||
return {
|
||||
categoricalSelection,
|
||||
annotations: state.annotations,
|
||||
colorScale: state.colors.scale,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
schema: state.world?.schema,
|
||||
world: state.world,
|
||||
crossfilter: state.crossfilter,
|
||||
schema: state.annoMatrix?.schema,
|
||||
ontology: state.ontology,
|
||||
isDilated,
|
||||
};
|
||||
@@ -95,50 +93,37 @@ class CategoryValue extends React.Component {
|
||||
handleDeleteValue = () => {
|
||||
const { dispatch, metadataField } = this.props;
|
||||
const label = this.getLabel();
|
||||
dispatch({
|
||||
type: "annotation: delete label",
|
||||
metadataField,
|
||||
label,
|
||||
});
|
||||
dispatch(actions.annotationDeleteLabelFromCategory(metadataField, label));
|
||||
};
|
||||
|
||||
handleAddCurrentSelectionToThisLabel = () => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
const { dispatch, metadataField } = this.props;
|
||||
const label = this.getLabel();
|
||||
dispatch({
|
||||
type: "annotation: label current cell selection",
|
||||
metadataField,
|
||||
categoryIndex,
|
||||
label,
|
||||
});
|
||||
dispatch(actions.annotationLabelCurrentSelection(metadataField, label));
|
||||
};
|
||||
|
||||
handleEditValue = (e) => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
const { dispatch, metadataField } = this.props;
|
||||
const { editedLabelText } = this.state;
|
||||
const label = this.getLabel();
|
||||
this.cancelEditMode();
|
||||
dispatch({
|
||||
type: "annotation: label edited",
|
||||
editedLabel: editedLabelText,
|
||||
metadataField,
|
||||
categoryIndex,
|
||||
label,
|
||||
});
|
||||
dispatch(
|
||||
actions.annotationRenameLabelInCategory(
|
||||
metadataField,
|
||||
label,
|
||||
editedLabelText
|
||||
)
|
||||
);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
handleCreateArbitraryLabel = (txt) => {
|
||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||
const { dispatch, metadataField } = this.props;
|
||||
const label = this.getLabel();
|
||||
this.cancelEditMode();
|
||||
dispatch({
|
||||
type: "annotation: label edited",
|
||||
metadataField,
|
||||
editedLabel: txt,
|
||||
categoryIndex,
|
||||
label,
|
||||
});
|
||||
dispatch(
|
||||
actions.annotationRenameLabelInCategory(metadataField, label, txt)
|
||||
);
|
||||
};
|
||||
|
||||
labelNameError = (name) => {
|
||||
@@ -185,13 +170,15 @@ class CategoryValue extends React.Component {
|
||||
} = this.props;
|
||||
const labels = categorySummary.categoryValues;
|
||||
const label = labels[categoryIndex];
|
||||
dispatch({
|
||||
type: "categorical metadata filter deselect",
|
||||
metadataField,
|
||||
categoryIndex,
|
||||
label,
|
||||
labels,
|
||||
});
|
||||
dispatch(
|
||||
actions.selectCategoricalMetadataAction(
|
||||
"categorical metadata filter deselect",
|
||||
metadataField,
|
||||
labels,
|
||||
label,
|
||||
false
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
shouldComponentUpdate = (nextProps, nextState) => {
|
||||
@@ -224,11 +211,8 @@ class CategoryValue extends React.Component {
|
||||
categoricalSelection[metadataField].get(label) !==
|
||||
newCategoricalSelection[metadataField].get(newLabel);
|
||||
|
||||
const worldChange = props.world !== nextProps.world;
|
||||
const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor;
|
||||
const annotationsChange = props.annotations !== nextProps.annotations;
|
||||
const crossfilterChange =
|
||||
props.isUserAnno && props.crossfilter !== nextProps.crossfilter;
|
||||
const editingLabel = state.editedLabelText !== nextState.editedLabelText;
|
||||
const dilationChange = props.isDilated !== nextProps.isDilated;
|
||||
|
||||
@@ -239,10 +223,8 @@ class CategoryValue extends React.Component {
|
||||
return (
|
||||
labelChanged ||
|
||||
valueSelectionChange ||
|
||||
worldChange ||
|
||||
colorAccessorChange ||
|
||||
annotationsChange ||
|
||||
crossfilterChange ||
|
||||
editingLabel ||
|
||||
dilationChange ||
|
||||
countChanged
|
||||
@@ -258,13 +240,15 @@ class CategoryValue extends React.Component {
|
||||
} = this.props;
|
||||
const labels = categorySummary.categoryValues;
|
||||
const label = labels[categoryIndex];
|
||||
dispatch({
|
||||
type: "categorical metadata filter select",
|
||||
metadataField,
|
||||
categoryIndex,
|
||||
label,
|
||||
labels,
|
||||
});
|
||||
dispatch(
|
||||
actions.selectCategoricalMetadataAction(
|
||||
"categorical metadata filter select",
|
||||
metadataField,
|
||||
labels,
|
||||
label,
|
||||
true
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
handleMouseEnter = () => {
|
||||
@@ -299,10 +283,11 @@ class CategoryValue extends React.Component {
|
||||
};
|
||||
|
||||
createHistogramBins = (
|
||||
world,
|
||||
metadataField,
|
||||
categoryData,
|
||||
colorAccessor,
|
||||
value,
|
||||
colorData,
|
||||
categoryValue,
|
||||
width,
|
||||
height
|
||||
) => {
|
||||
@@ -311,12 +296,8 @@ class CategoryValue extends React.Component {
|
||||
createHistogramBins fetches the continuous data in relation to the cells relevant to the category value.
|
||||
It then separates that data into 50 bins for drawing the mini-histogram
|
||||
*/
|
||||
const groupBy = world.obsAnnotations.col(metadataField);
|
||||
|
||||
const col =
|
||||
world.obsAnnotations.col(colorAccessor) ||
|
||||
world.varData.col(colorAccessor);
|
||||
|
||||
const groupBy = categoryData.col(metadataField);
|
||||
const col = colorData.icol(0);
|
||||
const range = col.summarize();
|
||||
|
||||
const histogramMap = col.histogram(
|
||||
@@ -325,8 +306,8 @@ class CategoryValue extends React.Component {
|
||||
groupBy
|
||||
); /* Because the signature changes we really need different names for histogram to differentiate signatures */
|
||||
|
||||
const bins = histogramMap.has(value)
|
||||
? histogramMap.get(value)
|
||||
const bins = histogramMap.has(categoryValue)
|
||||
? histogramMap.get(categoryValue)
|
||||
: new Array(50).fill(0);
|
||||
|
||||
const xScale = d3.scaleLinear().domain([0, bins.length]).range([0, width]);
|
||||
@@ -343,10 +324,13 @@ class CategoryValue extends React.Component {
|
||||
};
|
||||
|
||||
createStackedGraphBins = (
|
||||
world,
|
||||
metadataField,
|
||||
categoryData,
|
||||
colorAccessor,
|
||||
colorData,
|
||||
categoryValue,
|
||||
colorTable,
|
||||
schema,
|
||||
width
|
||||
) => {
|
||||
/*
|
||||
@@ -354,10 +338,8 @@ class CategoryValue extends React.Component {
|
||||
createOccupancyStack obtains a map showing the number if cells per colored value
|
||||
Using the colorScale a stack of colored bars is drawn representing the map
|
||||
*/
|
||||
const { schema } = world;
|
||||
|
||||
const groupBy = world.obsAnnotations.col(metadataField);
|
||||
const occupancyMap = world.obsAnnotations
|
||||
const groupBy = categoryData.col(metadataField);
|
||||
const occupancyMap = colorData
|
||||
.col(colorAccessor)
|
||||
.histogramCategorical(groupBy);
|
||||
|
||||
@@ -373,7 +355,7 @@ class CategoryValue extends React.Component {
|
||||
const categories =
|
||||
schema.annotations.obsByName[colorAccessor]?.categories;
|
||||
|
||||
const dfColumn = world.obsAnnotations.col(colorAccessor);
|
||||
const dfColumn = colorData.col(colorAccessor);
|
||||
const categoryValues = dfColumn.summarizeCategorical().categories;
|
||||
|
||||
return {
|
||||
@@ -390,13 +372,13 @@ class CategoryValue extends React.Component {
|
||||
return _currentLabelAsString(this.props);
|
||||
}
|
||||
|
||||
isAddCurrentSelectionDisabled(category, value) {
|
||||
isAddCurrentSelectionDisabled(crossfilter, category, value) {
|
||||
/*
|
||||
disable "add current selection to label", if one of the following is true:
|
||||
1. no cells are selected
|
||||
2. all currently selected cells already have this label, on this category
|
||||
*/
|
||||
const { crossfilter, world } = this.props;
|
||||
const { categoryData } = this.props;
|
||||
|
||||
// 1. no cells selected?
|
||||
if (crossfilter.countSelected() === 0) {
|
||||
@@ -405,12 +387,7 @@ class CategoryValue extends React.Component {
|
||||
// 2. all selected cells already have the label
|
||||
const mask = crossfilter.allSelectedMask();
|
||||
if (
|
||||
AnnotationsHelpers.allHaveLabelByMask(
|
||||
world.obsAnnotations,
|
||||
category,
|
||||
value,
|
||||
mask
|
||||
)
|
||||
AnnotationsHelpers.allHaveLabelByMask(categoryData, category, value, mask)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -422,11 +399,12 @@ class CategoryValue extends React.Component {
|
||||
const {
|
||||
categoricalSelection,
|
||||
colorAccessor,
|
||||
colorScale,
|
||||
metadataField,
|
||||
world,
|
||||
categoryData,
|
||||
colorData,
|
||||
colorTable,
|
||||
schema,
|
||||
} = this.props;
|
||||
|
||||
const isColorBy = metadataField === colorAccessor;
|
||||
|
||||
if (
|
||||
@@ -439,10 +417,13 @@ class CategoryValue extends React.Component {
|
||||
|
||||
const { domainValues, scale, domain, occupancy } =
|
||||
this.createStackedGraphBins(
|
||||
world,
|
||||
metadataField,
|
||||
categoryData,
|
||||
colorAccessor,
|
||||
colorData,
|
||||
categoryValue,
|
||||
colorTable,
|
||||
schema,
|
||||
CHART_WIDTH
|
||||
) ?? {};
|
||||
|
||||
@@ -454,7 +435,7 @@ class CategoryValue extends React.Component {
|
||||
<MiniStackedBar
|
||||
/* eslint-disable react/jsx-props-no-spreading -- Disable unneeded on next release of eslint-config-airbnb */
|
||||
{...{
|
||||
colorScale,
|
||||
colorTable,
|
||||
domainValues,
|
||||
scale,
|
||||
domain,
|
||||
@@ -471,10 +452,12 @@ class CategoryValue extends React.Component {
|
||||
const {
|
||||
categoricalSelection,
|
||||
colorAccessor,
|
||||
colorScale,
|
||||
world,
|
||||
metadataField,
|
||||
colorData,
|
||||
categoryData,
|
||||
colorTable,
|
||||
} = this.props;
|
||||
const colorScale = colorTable?.scale;
|
||||
|
||||
if (
|
||||
!this.shouldRenderStackedBarOrHistogram ||
|
||||
@@ -485,9 +468,10 @@ class CategoryValue extends React.Component {
|
||||
|
||||
const { xScale, yScale, bins } =
|
||||
this.createHistogramBins(
|
||||
world,
|
||||
metadataField,
|
||||
categoryData,
|
||||
colorAccessor,
|
||||
colorData,
|
||||
categoryValue,
|
||||
CHART_WIDTH,
|
||||
VALUE_HEIGHT
|
||||
@@ -517,9 +501,8 @@ class CategoryValue extends React.Component {
|
||||
metadataField,
|
||||
categoryIndex,
|
||||
colorAccessor,
|
||||
colorScale,
|
||||
colorTable,
|
||||
i,
|
||||
schema,
|
||||
isUserAnno,
|
||||
annotations,
|
||||
ontology,
|
||||
@@ -529,6 +512,7 @@ class CategoryValue extends React.Component {
|
||||
isDilated,
|
||||
categorySummary,
|
||||
} = this.props;
|
||||
const colorScale = colorTable?.scale;
|
||||
const ontologyEnabled = ontology?.enabled ?? false;
|
||||
|
||||
const { editedLabelText } = this.state;
|
||||
@@ -543,11 +527,7 @@ class CategoryValue extends React.Component {
|
||||
|
||||
/* this is the color scale, so add swatches below */
|
||||
const isColorBy = metadataField === colorAccessor;
|
||||
let categories = null;
|
||||
|
||||
if (isColorBy && schema) {
|
||||
categories = schema.annotations.obsByName[colorAccessor]?.categories;
|
||||
}
|
||||
const { categoryValueIndices } = categorySummary;
|
||||
|
||||
const editModeActive =
|
||||
isUserAnno &&
|
||||
@@ -555,7 +535,7 @@ class CategoryValue extends React.Component {
|
||||
annotations.isEditingLabelName &&
|
||||
annotations.labelEditable.label === categoryIndex;
|
||||
|
||||
const valueToggleLabel = `value-toggle-checkbox-${displayString}`;
|
||||
const valueToggleLabel = `value-toggle-checkbox-${metadataField}-${displayString}`;
|
||||
|
||||
const LEFT_MARGIN = 60;
|
||||
const CHECKBOX = 26;
|
||||
@@ -719,14 +699,14 @@ class CategoryValue extends React.Component {
|
||||
</span>
|
||||
|
||||
<svg
|
||||
display={isColorBy && categories ? "auto" : "none"}
|
||||
display={isColorBy && categoryValueIndices ? "auto" : "none"}
|
||||
style={{
|
||||
marginLeft: 5,
|
||||
width: VALUE_HEIGHT,
|
||||
height: VALUE_HEIGHT,
|
||||
backgroundColor:
|
||||
isColorBy && categories
|
||||
? colorScale(categories.indexOf(value))
|
||||
isColorBy && categoryValueIndices
|
||||
? colorScale(categoryValueIndices.get(value))
|
||||
: "inherit",
|
||||
}}
|
||||
/>
|
||||
@@ -741,32 +721,37 @@ class CategoryValue extends React.Component {
|
||||
position={Position.RIGHT_TOP}
|
||||
content={
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon="plus"
|
||||
data-testclass="handleAddCurrentSelectionToThisLabel"
|
||||
data-testid={`${metadataField}:${displayString}:add-current-selection-to-this-label`}
|
||||
onClick={this.handleAddCurrentSelectionToThisLabel}
|
||||
text={
|
||||
<span>
|
||||
Re-label currently selected cells as
|
||||
<span
|
||||
style={{
|
||||
fontStyle:
|
||||
displayString ===
|
||||
globals.unassignedCategoryLabel
|
||||
? "italic"
|
||||
: "auto",
|
||||
}}
|
||||
>
|
||||
{` ${displayString}`}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
disabled={this.isAddCurrentSelectionDisabled(
|
||||
metadataField,
|
||||
value
|
||||
<CategoryCrossfilterContext.Consumer>
|
||||
{(crossfilter) => (
|
||||
<MenuItem
|
||||
icon="plus"
|
||||
data-testclass="handleAddCurrentSelectionToThisLabel"
|
||||
data-testid={`${metadataField}:${displayString}:add-current-selection-to-this-label`}
|
||||
onClick={this.handleAddCurrentSelectionToThisLabel}
|
||||
text={
|
||||
<span>
|
||||
Re-label currently selected cells as
|
||||
<span
|
||||
style={{
|
||||
fontStyle:
|
||||
displayString ===
|
||||
globals.unassignedCategoryLabel
|
||||
? "italic"
|
||||
: "auto",
|
||||
}}
|
||||
>
|
||||
{` ${displayString}`}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
disabled={this.isAddCurrentSelectionDisabled(
|
||||
crossfilter,
|
||||
metadataField,
|
||||
value
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</CategoryCrossfilterContext.Consumer>
|
||||
{displayString !== globals.unassignedCategoryLabel ? (
|
||||
<MenuItem
|
||||
icon="edit"
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
Classes,
|
||||
} from "@blueprintjs/core";
|
||||
|
||||
@connect()
|
||||
@connect((state) => ({
|
||||
schema: state.annoMatrix?.schema,
|
||||
}))
|
||||
class Occupancy extends React.PureComponent {
|
||||
_WIDTH = 100;
|
||||
|
||||
@@ -21,16 +23,17 @@ class Occupancy extends React.PureComponent {
|
||||
createHistogram fetches the continous data in relation to the cells releveant to the catagory value.
|
||||
It then seperates that data into 50 bins for drawing the mini-histogram
|
||||
*/
|
||||
const { world, metadataField, colorAccessor, categoryValue } = this.props;
|
||||
const {
|
||||
metadataField,
|
||||
categoryData,
|
||||
colorData,
|
||||
categoryValue,
|
||||
} = this.props;
|
||||
|
||||
if (!this.canvas) return;
|
||||
|
||||
const groupBy = world.obsAnnotations.col(metadataField);
|
||||
|
||||
const col =
|
||||
world.obsAnnotations.col(colorAccessor) ||
|
||||
world.varData.col(colorAccessor);
|
||||
|
||||
const groupBy = categoryData.col(metadataField);
|
||||
const col = colorData.icol(0);
|
||||
const range = col.summarize();
|
||||
|
||||
const histogramMap = col.histogram(
|
||||
@@ -39,7 +42,6 @@ class Occupancy extends React.PureComponent {
|
||||
groupBy
|
||||
); /* Because the signature changes we really need different names for histogram to differentiate signatures */
|
||||
|
||||
// const categoryValue = category.categoryValues[categoryIndex];
|
||||
const bins = histogramMap.has(categoryValue)
|
||||
? histogramMap.get(categoryValue)
|
||||
: new Array(50).fill(0);
|
||||
@@ -79,20 +81,22 @@ class Occupancy extends React.PureComponent {
|
||||
Using the colorScale a stack of colored bars is drawn representing the map
|
||||
*/
|
||||
const {
|
||||
world,
|
||||
metadataField,
|
||||
categoryData,
|
||||
colorAccessor,
|
||||
categoryValue,
|
||||
colorScale,
|
||||
colorTable,
|
||||
schema,
|
||||
colorData,
|
||||
} = this.props;
|
||||
const { schema } = world;
|
||||
const { scale: colorScale } = colorTable;
|
||||
|
||||
const ctx = this.canvas?.getContext("2d");
|
||||
|
||||
if (!ctx) return;
|
||||
|
||||
const groupBy = world.obsAnnotations.col(metadataField);
|
||||
const occupancyMap = world.obsAnnotations
|
||||
const groupBy = categoryData.col(metadataField);
|
||||
const occupancyMap = colorData
|
||||
.col(colorAccessor)
|
||||
.histogramCategorical(groupBy);
|
||||
|
||||
@@ -109,7 +113,7 @@ class Occupancy extends React.PureComponent {
|
||||
schema.annotations.obsByName[colorAccessor]?.categories;
|
||||
|
||||
let currentOffset = 0;
|
||||
const dfColumn = world.obsAnnotations.col(colorAccessor);
|
||||
const dfColumn = colorData.col(colorAccessor);
|
||||
const categoryValues = dfColumn.summarizeCategorical().categories;
|
||||
|
||||
let o;
|
||||
|
||||
@@ -1,97 +1,28 @@
|
||||
// jshint esversion: 6
|
||||
/* rc slider https://www.npmjs.com/package/rc-slider */
|
||||
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Button } from "@blueprintjs/core";
|
||||
|
||||
import * as globals from "../../globals";
|
||||
import HistogramBrush from "../brushableHistogram";
|
||||
|
||||
@connect((state) => ({
|
||||
obsAnnotations: state.world?.obsAnnotations,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
colorScale: state.colors.scale,
|
||||
schema: state.world?.schema,
|
||||
schema: state.annoMatrix?.schema,
|
||||
}))
|
||||
class Continuous extends React.PureComponent {
|
||||
static renderIsStillLoading(zebra, key) {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
padding: globals.leftSidebarSectionPadding,
|
||||
backgroundColor: zebra % 2 === 0 ? globals.lightestGrey : "white",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
justifyItems: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 30 }} />
|
||||
<div style={{ display: "flex", alignSelf: "center" }}>
|
||||
<span style={{ fontStyle: "italic" }}>{key}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Button minimal loading intent="primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { obsAnnotations, schema } = this.props;
|
||||
|
||||
/* initial value for iterator to simulate index, ranges is an object */
|
||||
const { schema } = this.props;
|
||||
if (!schema) return null;
|
||||
const obsIndex = schema.annotations.obs.index;
|
||||
const allContinuousNames = schema.annotations.obs.columns
|
||||
.filter((col) => col.type === "int32" || col.type === "float32")
|
||||
.filter((col) => col.name !== obsIndex)
|
||||
.map((col) => col.name);
|
||||
|
||||
/* initial value for iterator to simulate index, ranges is an object */
|
||||
let zebra = 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{allContinuousNames.map((key) => {
|
||||
if (!obsAnnotations.hasCol(key)) {
|
||||
// still loading!
|
||||
zebra += 1;
|
||||
return Continuous.renderIsStillLoading(zebra, key);
|
||||
}
|
||||
|
||||
// data loaded and available
|
||||
const summary = obsAnnotations.col(key).summarize();
|
||||
const nonFiniteExtent =
|
||||
summary.min === undefined ||
|
||||
summary.max === undefined ||
|
||||
Number.isNaN(summary.min) ||
|
||||
Number.isNaN(summary.max);
|
||||
if (!summary.categorical && !nonFiniteExtent) {
|
||||
zebra += 1;
|
||||
return (
|
||||
<HistogramBrush
|
||||
key={key}
|
||||
field={key}
|
||||
isObs
|
||||
zebra={zebra % 2 === 0}
|
||||
ranges={summary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{allContinuousNames.map((key, zebra) => (
|
||||
<HistogramBrush key={key} field={key} isObs zebra={zebra % 2 === 0} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import { connect } from "react-redux";
|
||||
import * as d3 from "d3";
|
||||
import { interpolateCool } from "d3-scale-chromatic";
|
||||
|
||||
import {
|
||||
createColorTable,
|
||||
createColorQuery,
|
||||
} from "../../util/stateManager/colorHelpers";
|
||||
|
||||
// create continuous color legend
|
||||
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
|
||||
const continuous = (selectorId, colorscale, colorAccessor) => {
|
||||
@@ -101,38 +106,71 @@ const continuous = (selectorId, colorscale, colorAccessor) => {
|
||||
};
|
||||
|
||||
@connect((state) => ({
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
colorScale: state.colors.scale,
|
||||
annoMatrix: state.annoMatrix,
|
||||
colors: state.colors,
|
||||
}))
|
||||
class ContinuousLegend extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.ref = null;
|
||||
this.state = {
|
||||
colorAccessor: null,
|
||||
colorScale: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateState(null);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { colorAccessor, colorScale } = this.props;
|
||||
const range = colorScale?.range;
|
||||
const [domainMin, domainMax] = colorScale?.domain?.() ?? [0, 0];
|
||||
this.updateState(prevProps);
|
||||
}
|
||||
|
||||
async updateState(prevProps) {
|
||||
const { annoMatrix, colors } = this.props;
|
||||
if (!colors || !annoMatrix) return;
|
||||
|
||||
if (colors !== prevProps?.colors || annoMatrix !== prevProps?.annoMatrix) {
|
||||
const { schema } = annoMatrix;
|
||||
const { colorMode, colorAccessor, userColors } = colors;
|
||||
const colorQuery = createColorQuery(colorMode, colorAccessor, schema);
|
||||
const colorDf = colorQuery ? await annoMatrix.fetch(...colorQuery) : null;
|
||||
const colorTable = createColorTable(
|
||||
colorMode,
|
||||
colorAccessor,
|
||||
colorDf,
|
||||
schema,
|
||||
userColors
|
||||
);
|
||||
|
||||
const colorScale = colorTable.scale;
|
||||
const range = colorScale?.range;
|
||||
const [domainMin, domainMax] = colorScale?.domain?.() ?? [0, 0];
|
||||
|
||||
if (
|
||||
prevProps.colorAccessor !== colorAccessor ||
|
||||
prevProps.colorScale !== colorScale
|
||||
) {
|
||||
/* always remove it, if it's not continuous we don't put it back. */
|
||||
d3.select("#continuous_legend").selectAll("*").remove();
|
||||
}
|
||||
|
||||
if (colorAccessor && colorScale && range && domainMin < domainMax) {
|
||||
/* fragile! continuous range is 0 to 1, not [#fa4b2c, ...], make this a flag? */
|
||||
if (range()[0][0] !== "#") {
|
||||
continuous(
|
||||
"#continuous_legend",
|
||||
d3.scaleSequential(interpolateCool).domain(colorScale.domain()),
|
||||
colorAccessor
|
||||
);
|
||||
if (colorAccessor && colorScale && range && domainMin < domainMax) {
|
||||
/* fragile! continuous range is 0 to 1, not [#fa4b2c, ...], make this a flag? */
|
||||
if (range()[0][0] !== "#") {
|
||||
continuous(
|
||||
"#continuous_legend",
|
||||
d3.scaleSequential(interpolateCool).domain(colorScale.domain()),
|
||||
colorAccessor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
colorAccessor,
|
||||
colorScale: colorTable.scale,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { colorAccessor, colorScale } = this.props;
|
||||
|
||||
const { colorAccessor, colorScale } = this.state;
|
||||
if (
|
||||
colorScale?.domain &&
|
||||
colorScale.domain()[1] === colorScale.domain()[0]
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Position, Toaster, Intent } from "@blueprintjs/core";
|
||||
const ToastTopCenter = Toaster.create({
|
||||
className: "recipe-toaster",
|
||||
position: Position.TOP,
|
||||
maxToasts: 4,
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -23,12 +24,15 @@ export const keepAroundErrorToast = (message) =>
|
||||
/*
|
||||
a hard network error
|
||||
*/
|
||||
export const postNetworkErrorToast = (message) =>
|
||||
ToastTopCenter.show({
|
||||
message,
|
||||
timeout: 30000,
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
export const postNetworkErrorToast = (message, key = undefined) =>
|
||||
ToastTopCenter.show(
|
||||
{
|
||||
message,
|
||||
timeout: 30000,
|
||||
intent: Intent.DANGER,
|
||||
},
|
||||
key
|
||||
);
|
||||
|
||||
/*
|
||||
Async message to user
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
/* rc slider https://www.npmjs.com/package/rc-slider */
|
||||
|
||||
import React from "react";
|
||||
@@ -34,9 +33,6 @@ const renderGene = (fuzzySortResult, { handleClick, modifiers }) => {
|
||||
active={modifiers.active}
|
||||
disabled={modifiers.disabled}
|
||||
data-testid={`suggest-menu-item-${geneName}`}
|
||||
// Use of annotations in this way is incorrect and dataset specific.
|
||||
// See https://github.com/chanzuckerberg/cellxgene/issues/483
|
||||
// label={gene.n_counts}
|
||||
key={geneName}
|
||||
onClick={(g) =>
|
||||
/* this fires when user clicks a menu item */
|
||||
@@ -56,11 +52,9 @@ const filterGenes = (query, genes) =>
|
||||
|
||||
@connect((state) => {
|
||||
return {
|
||||
obsAnnotations: state.world?.obsAnnotations,
|
||||
annoMatrix: state.annoMatrix,
|
||||
userDefinedGenes: state.controls.userDefinedGenes,
|
||||
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
|
||||
world: state.world,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
differential: state.differential,
|
||||
};
|
||||
})
|
||||
@@ -71,9 +65,19 @@ class AddGenes extends React.Component {
|
||||
bulkAdd: "",
|
||||
tab: "autosuggest",
|
||||
activeItem: null,
|
||||
geneNames: [],
|
||||
status: "pending",
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
this.updateState(prevProps);
|
||||
}
|
||||
|
||||
_genesToUpper = (listGenes) => {
|
||||
// Has to be a Map to preserve index
|
||||
const upperGenes = new Map();
|
||||
@@ -88,9 +92,8 @@ class AddGenes extends React.Component {
|
||||
_memoGenesToUpper = memoize(this._genesToUpper, (arr) => arr);
|
||||
|
||||
handleBulkAddClick = () => {
|
||||
const { world, dispatch, userDefinedGenes } = this.props;
|
||||
const varIndexName = world.schema.annotations.var.index;
|
||||
const { bulkAdd } = this.state;
|
||||
const { dispatch, userDefinedGenes } = this.props;
|
||||
const { bulkAdd, geneNames } = this.state;
|
||||
|
||||
/*
|
||||
test:
|
||||
@@ -98,18 +101,14 @@ class AddGenes extends React.Component {
|
||||
*/
|
||||
if (bulkAdd !== "") {
|
||||
const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), "");
|
||||
console.log("geneExpression genes", genes);
|
||||
if (genes.length === 0) {
|
||||
return keepAroundErrorToast("Must enter a gene name.");
|
||||
}
|
||||
const worldGenes =
|
||||
world.varAnnotations?.col(varIndexName)?.asArray() || [];
|
||||
|
||||
// These gene lists are unique enough where memoization is useless
|
||||
const upperGenes = this._genesToUpper(genes);
|
||||
const upperUserDefinedGenes = this._genesToUpper(userDefinedGenes);
|
||||
|
||||
const upperWorldGenes = this._memoGenesToUpper(worldGenes);
|
||||
const upperGeneNames = this._memoGenesToUpper(geneNames);
|
||||
|
||||
dispatch({ type: "bulk user defined gene start" });
|
||||
|
||||
@@ -119,7 +118,7 @@ class AddGenes extends React.Component {
|
||||
return keepAroundErrorToast("That gene already exists");
|
||||
}
|
||||
|
||||
const indexOfGene = upperWorldGenes.get(upperGene);
|
||||
const indexOfGene = upperGeneNames.get(upperGene);
|
||||
|
||||
if (indexOfGene === undefined) {
|
||||
return keepAroundErrorToast(
|
||||
@@ -129,7 +128,7 @@ class AddGenes extends React.Component {
|
||||
);
|
||||
}
|
||||
return dispatch(
|
||||
actions.requestUserDefinedGene(worldGenes[indexOfGene])
|
||||
actions.requestUserDefinedGene(geneNames[indexOfGene])
|
||||
);
|
||||
})
|
||||
).then(
|
||||
@@ -142,6 +141,27 @@ class AddGenes extends React.Component {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
async updateState(prevProps) {
|
||||
const { annoMatrix } = this.props;
|
||||
if (!annoMatrix) return;
|
||||
if (annoMatrix !== prevProps?.annoMatrix) {
|
||||
const { schema } = annoMatrix;
|
||||
const varIndex = schema.annotations.var.index;
|
||||
|
||||
this.setState({ status: "pending" });
|
||||
try {
|
||||
const df = await annoMatrix.fetch("var", varIndex);
|
||||
this.setState({
|
||||
status: "success",
|
||||
geneNames: df.col(varIndex).asArray(),
|
||||
});
|
||||
} catch (error) {
|
||||
this.setState({ status: "error" });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
placeholderGeneNames() {
|
||||
/*
|
||||
return a string containing gene name suggestions for use as a user hint.
|
||||
@@ -151,10 +171,7 @@ class AddGenes extends React.Component {
|
||||
|
||||
NOTE: the random selection means it will re-render constantly.
|
||||
*/
|
||||
const { world } = this.props;
|
||||
const { varAnnotations } = world;
|
||||
const varIndexName = world.schema.annotations.var.index;
|
||||
const geneNames = varAnnotations.col(varIndexName).asArray();
|
||||
const { geneNames } = this.state;
|
||||
if (geneNames.length > 0) {
|
||||
const placeholder = [];
|
||||
let len = geneNames.length;
|
||||
@@ -175,8 +192,8 @@ class AddGenes extends React.Component {
|
||||
}
|
||||
|
||||
handleClick(g) {
|
||||
const { world, dispatch, userDefinedGenes } = this.props;
|
||||
const varIndexName = world.schema.annotations.var.index;
|
||||
const { dispatch, userDefinedGenes } = this.props;
|
||||
const { geneNames } = this.state;
|
||||
if (!g) return;
|
||||
const gene = g.target;
|
||||
if (userDefinedGenes.indexOf(gene) !== -1) {
|
||||
@@ -185,27 +202,21 @@ class AddGenes extends React.Component {
|
||||
postUserErrorToast(
|
||||
`That's too many genes, you can have at most ${globals.maxUserDefinedGenes} user defined genes`
|
||||
);
|
||||
} else if (
|
||||
world.varAnnotations.col(varIndexName).indexOf(gene) === undefined
|
||||
) {
|
||||
} else if (geneNames.indexOf(gene) === undefined) {
|
||||
postUserErrorToast("That doesn't appear to be a valid gene name.");
|
||||
} else {
|
||||
dispatch({ type: "single user defined gene start" });
|
||||
dispatch(actions.requestUserDefinedGene(gene)).then(
|
||||
() => dispatch({ type: "single user defined gene complete" }),
|
||||
() => dispatch({ type: "single user defined gene error" })
|
||||
);
|
||||
dispatch(actions.requestUserDefinedGene(gene));
|
||||
dispatch({ type: "single user defined gene complete" });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { world, userDefinedGenesLoading } = this.props;
|
||||
const varIndexName = world?.schema?.annotations?.var?.index;
|
||||
const varIndex = world?.varAnnotations?.col(varIndexName)?.asArray();
|
||||
const { tab, bulkAdd, activeItem } = this.state;
|
||||
const { userDefinedGenesLoading } = this.props;
|
||||
const { tab, bulkAdd, activeItem, status, geneNames } = this.state;
|
||||
|
||||
// may still be loading!
|
||||
if (!varIndex) return null;
|
||||
if (status !== "success") return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -263,7 +274,7 @@ class AddGenes extends React.Component {
|
||||
itemListPredicate={filterGenes}
|
||||
onActiveItemChange={(item) => this.setState({ activeItem: item })}
|
||||
itemRenderer={renderGene}
|
||||
items={varIndex || ["No genes"]}
|
||||
items={geneNames || ["No genes"]}
|
||||
popoverProps={{ minimal: true }}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
/* rc slider https://www.npmjs.com/package/rc-slider */
|
||||
|
||||
import React from "react";
|
||||
@@ -10,23 +9,13 @@ import AddGenes from "./addGenes";
|
||||
|
||||
@connect((state) => {
|
||||
return {
|
||||
obsAnnotations: state.world?.obsAnnotations,
|
||||
userDefinedGenes: state.controls.userDefinedGenes,
|
||||
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
|
||||
world: state.world,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
differential: state.differential,
|
||||
};
|
||||
})
|
||||
class GeneExpression extends React.Component {
|
||||
render() {
|
||||
const { world, userDefinedGenes, differential } = this.props;
|
||||
const varIndexName = world?.schema?.annotations?.var?.index;
|
||||
const varIndex = world?.varAnnotations?.col(varIndexName)?.asArray();
|
||||
|
||||
// may still be loading!
|
||||
if (!varIndex) return null;
|
||||
|
||||
const { userDefinedGenes, differential } = this.props;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -35,19 +24,13 @@ class GeneExpression extends React.Component {
|
||||
>
|
||||
<div>
|
||||
<AddGenes />
|
||||
{world && userDefinedGenes.length > 0
|
||||
{userDefinedGenes.length > 0
|
||||
? _.map(userDefinedGenes, (geneName, index) => {
|
||||
const values = world.varData.col(geneName);
|
||||
if (!values) {
|
||||
return null;
|
||||
}
|
||||
const summary = values.summarize();
|
||||
return (
|
||||
<HistogramBrush
|
||||
key={geneName}
|
||||
field={geneName}
|
||||
zebra={index % 2 === 0}
|
||||
ranges={summary}
|
||||
isUserDefined
|
||||
/>
|
||||
);
|
||||
@@ -57,18 +40,11 @@ class GeneExpression extends React.Component {
|
||||
<div>
|
||||
{differential.diffExp
|
||||
? _.map(differential.diffExp, (value, index) => {
|
||||
const name = world.varAnnotations.at(value[0], varIndexName);
|
||||
const values = world.varData.col(name);
|
||||
if (!values) {
|
||||
return null;
|
||||
}
|
||||
const summary = values.summarize();
|
||||
return (
|
||||
<HistogramBrush
|
||||
key={name}
|
||||
field={name}
|
||||
key={value[0]}
|
||||
field={value[0]}
|
||||
zebra={index % 2 === 0}
|
||||
ranges={summary}
|
||||
isDiffExp
|
||||
logFoldChange={value[1]}
|
||||
pval={value[2]}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import * as d3 from "d3";
|
||||
import { connect } from "react-redux";
|
||||
import { connect, shallowEqual } from "react-redux";
|
||||
import { mat3, vec2 } from "gl-matrix";
|
||||
import _regl from "regl";
|
||||
import memoize from "memoize-one";
|
||||
import Async from "react-async";
|
||||
|
||||
import setupSVGandBrushElements from "./setupSVGandBrush";
|
||||
import _camera from "../../util/camera";
|
||||
import _drawPoints from "./drawPointsRegl";
|
||||
import { isTypedArray } from "../../util/typeHelpers";
|
||||
import {
|
||||
createColorTable,
|
||||
createColorQuery,
|
||||
} from "../../util/stateManager/colorHelpers";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
import GraphOverlayLayer from "./overlays/graphOverlayLayer";
|
||||
import CentroidLabels from "./overlays/centroidLabels";
|
||||
import actions from "../../actions";
|
||||
import renderThrottle from "../../util/renderThrottle";
|
||||
|
||||
/*
|
||||
Simple 2D transforms control all point painting. There are three:
|
||||
@@ -53,38 +60,49 @@ function createModelTF() {
|
||||
return m;
|
||||
}
|
||||
|
||||
function renderThrottle(callback) {
|
||||
/*
|
||||
This wraps a call to requestAnimationFrame(), enforcing a single
|
||||
render callback at any given time (ie, you can call this any number
|
||||
of times, and it will coallesce multiple inter-frame calls into a
|
||||
single render).
|
||||
*/
|
||||
let rafCurrentlyInProgress = null;
|
||||
return function f() {
|
||||
if (rafCurrentlyInProgress) return;
|
||||
const context = this;
|
||||
rafCurrentlyInProgress = window.requestAnimationFrame(() => {
|
||||
callback.apply(context);
|
||||
rafCurrentlyInProgress = null;
|
||||
});
|
||||
};
|
||||
}
|
||||
const flagSelected = 1;
|
||||
const flagNaN = 2;
|
||||
const flagHighlight = 4;
|
||||
|
||||
@connect((state) => ({
|
||||
universe: state.universe,
|
||||
world: state.world,
|
||||
crossfilter: state.crossfilter,
|
||||
colorRGB: state.colors.rgb,
|
||||
annoMatrix: state.annoMatrix,
|
||||
crossfilter: state.obsCrossfilter,
|
||||
selectionTool: state.graphSelection.tool,
|
||||
currentSelection: state.graphSelection.selection,
|
||||
layoutChoice: state.layoutChoice,
|
||||
centroidLabels: state.centroidLabels,
|
||||
graphInteractionMode: state.controls.graphInteractionMode,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
colors: state.colors,
|
||||
pointDilation: state.pointDilation,
|
||||
}))
|
||||
class Graph extends React.Component {
|
||||
static createReglState(canvas) {
|
||||
/*
|
||||
Must be created for each canvas
|
||||
*/
|
||||
// setup canvas, webgl draw function and camera
|
||||
const camera = _camera(canvas);
|
||||
const regl = _regl(canvas);
|
||||
const drawPoints = _drawPoints(regl);
|
||||
|
||||
// preallocate webgl buffers
|
||||
const pointBuffer = regl.buffer();
|
||||
const colorBuffer = regl.buffer();
|
||||
const flagBuffer = regl.buffer();
|
||||
|
||||
return {
|
||||
camera,
|
||||
regl,
|
||||
drawPoints,
|
||||
pointBuffer,
|
||||
colorBuffer,
|
||||
flagBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
static watchAsync(props, prevProps) {
|
||||
return !shallowEqual(props.watchProps, prevProps.watchProps);
|
||||
}
|
||||
|
||||
computePointPositions = memoize((X, Y, modelTF) => {
|
||||
/*
|
||||
compute the model coordinate for each point
|
||||
@@ -111,18 +129,18 @@ class Graph extends React.Component {
|
||||
});
|
||||
|
||||
computeSelectedFlags = memoize(
|
||||
(crossfilter, flagSelected, flagUnselected) => {
|
||||
(crossfilter, _flagSelected, _flagUnselected) => {
|
||||
const x = crossfilter.fillByIsSelected(
|
||||
new Float32Array(crossfilter.size()),
|
||||
flagSelected,
|
||||
flagUnselected
|
||||
_flagSelected,
|
||||
_flagUnselected
|
||||
);
|
||||
return x;
|
||||
}
|
||||
);
|
||||
|
||||
computePointFlags = memoize(
|
||||
(world, crossfilter, colorAccessor, pointDilation) => {
|
||||
(crossfilter, colorByData, pointDilationData, pointDilationLabel) => {
|
||||
/*
|
||||
We communicate with the shader using three flags:
|
||||
- isNaN -- the value is a NaN. Only makes sense when we have a colorAccessor
|
||||
@@ -136,32 +154,17 @@ class Graph extends React.Component {
|
||||
continuous metadata, as they rely on different tests, and some of the flags
|
||||
(eg, isNaN) are meaningless in the face of categorical metadata.
|
||||
*/
|
||||
|
||||
const flagSelected = 1;
|
||||
const flagNaN = 2;
|
||||
const flagHighlight = 4;
|
||||
|
||||
const flags = this.computeSelectedFlags(
|
||||
crossfilter,
|
||||
flagSelected,
|
||||
0
|
||||
).slice();
|
||||
|
||||
const { metadataField, categoryField } = pointDilation;
|
||||
const highlightData = metadataField
|
||||
? world.obsAnnotations.col(metadataField)?.asArray()
|
||||
: null;
|
||||
const colorByColumn = colorAccessor
|
||||
? world.obsAnnotations.col(colorAccessor)?.asArray() ||
|
||||
world.varData.col(colorAccessor)?.asArray()
|
||||
: null;
|
||||
const colorByData =
|
||||
colorByColumn && isTypedArray(colorByColumn) ? colorByColumn : null;
|
||||
|
||||
if (colorByData || highlightData) {
|
||||
if (colorByData || pointDilationData) {
|
||||
for (let i = 0, len = flags.length; i < len; i += 1) {
|
||||
if (highlightData) {
|
||||
flags[i] += highlightData[i] === categoryField ? flagHighlight : 0;
|
||||
if (pointDilationData) {
|
||||
flags[i] +=
|
||||
pointDilationData[i] === pointDilationLabel ? flagHighlight : 0;
|
||||
}
|
||||
if (colorByData) {
|
||||
flags[i] += Number.isFinite(colorByData[i]) ? 0 : flagNaN;
|
||||
@@ -175,167 +178,85 @@ class Graph extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const viewport = this.getViewportDimensions();
|
||||
this.count = 0;
|
||||
this.renderCache = {
|
||||
X: null,
|
||||
Y: null,
|
||||
positions: null,
|
||||
colors: null,
|
||||
sizes: null,
|
||||
flags: null,
|
||||
};
|
||||
this.reglCanvas = null;
|
||||
this.cachedAsyncProps = null;
|
||||
const modelTF = createModelTF();
|
||||
this.state = {
|
||||
toolSVG: null,
|
||||
tool: null,
|
||||
container: null,
|
||||
cameraRender: 0,
|
||||
viewport,
|
||||
|
||||
// projection
|
||||
camera: null,
|
||||
modelTF,
|
||||
modelInvTF: mat3.invert([], modelTF),
|
||||
projectionTF: null,
|
||||
|
||||
// regl state
|
||||
regl: null,
|
||||
drawPoints: null,
|
||||
pointBuffer: null,
|
||||
colorBuffer: null,
|
||||
flagBuffer: null,
|
||||
|
||||
// component rendering derived state - these must stay synchronized
|
||||
// with the reducer state they were generated from.
|
||||
layoutState: {
|
||||
layoutDf: null,
|
||||
layoutChoice: null,
|
||||
},
|
||||
colorState: {
|
||||
colors: null,
|
||||
colorDf: null,
|
||||
colorTable: null,
|
||||
},
|
||||
pointDilationState: {
|
||||
pointDilation: null,
|
||||
pointDilationDf: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("resize", this.handleResize);
|
||||
|
||||
// setup canvas, webgl draw function and camera
|
||||
const camera = _camera(this.reglCanvas);
|
||||
const regl = _regl(this.reglCanvas);
|
||||
const drawPoints = _drawPoints(regl);
|
||||
|
||||
// preallocate webgl buffers
|
||||
const pointBuffer = regl.buffer();
|
||||
const colorBuffer = regl.buffer();
|
||||
const flagBuffer = regl.buffer();
|
||||
|
||||
// create all default rendering transformations
|
||||
const modelTF = createModelTF();
|
||||
const projectionTF = createProjectionTF(
|
||||
this.reglCanvas.width,
|
||||
this.reglCanvas.height
|
||||
);
|
||||
|
||||
// initial draw to canvas
|
||||
this.renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
flagBuffer,
|
||||
camera,
|
||||
projectionTF
|
||||
);
|
||||
const { viewport } = this.state;
|
||||
const projectionTF = createProjectionTF(viewport.width, viewport.height);
|
||||
|
||||
this.setState({
|
||||
regl,
|
||||
drawPoints,
|
||||
pointBuffer,
|
||||
colorBuffer,
|
||||
flagBuffer,
|
||||
camera,
|
||||
modelTF,
|
||||
modelInvTF: mat3.invert([], modelTF),
|
||||
projectionTF,
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const { renderCache } = this;
|
||||
const {
|
||||
world,
|
||||
crossfilter,
|
||||
colorRGB,
|
||||
selectionTool,
|
||||
currentSelection,
|
||||
layoutChoice,
|
||||
graphInteractionMode,
|
||||
pointDilation,
|
||||
colorAccessor,
|
||||
} = this.props;
|
||||
const { regl, toolSVG, camera, modelTF, viewport } = this.state;
|
||||
const { toolSVG, viewport } = this.state;
|
||||
let { projectionTF } = this.state;
|
||||
const hasResized =
|
||||
prevState.viewport.height !== this.reglCanvas.height ||
|
||||
prevState.viewport.width !== this.reglCanvas.width;
|
||||
prevState.viewport.height !== viewport.height ||
|
||||
prevState.viewport.width !== viewport.width;
|
||||
let stateChanges = {};
|
||||
let needsRepaint = hasResized;
|
||||
|
||||
if (regl && world && crossfilter) {
|
||||
/* update the regl and point rendering state */
|
||||
const { obsLayout, nObs } = world;
|
||||
const { drawPoints, pointBuffer, colorBuffer, flagBuffer } = this.state;
|
||||
|
||||
if (hasResized) {
|
||||
projectionTF = createProjectionTF(
|
||||
this.reglCanvas.width,
|
||||
this.reglCanvas.height
|
||||
);
|
||||
stateChanges = {
|
||||
...stateChanges,
|
||||
projectionTF,
|
||||
};
|
||||
}
|
||||
|
||||
/* coordinates for each point */
|
||||
const X = obsLayout.col(layoutChoice.currentDimNames[0]).asArray();
|
||||
const Y = obsLayout.col(layoutChoice.currentDimNames[1]).asArray();
|
||||
const newPositions = this.computePointPositions(X, Y, modelTF);
|
||||
if (renderCache.positions !== newPositions) {
|
||||
/* update our cache & GL if the buffer changes */
|
||||
renderCache.positions = newPositions;
|
||||
pointBuffer({ data: newPositions, dimension: 2 });
|
||||
needsRepaint = true;
|
||||
}
|
||||
|
||||
/* colors for each point */
|
||||
const newColors = this.computePointColors(colorRGB);
|
||||
if (renderCache.colors !== newColors) {
|
||||
/* update our cache & GL if the buffer changes */
|
||||
renderCache.colors = newColors;
|
||||
colorBuffer({ data: newColors, dimension: 3 });
|
||||
needsRepaint = true;
|
||||
}
|
||||
|
||||
/* flags for each point */
|
||||
const newFlags = this.computePointFlags(
|
||||
world,
|
||||
crossfilter,
|
||||
colorAccessor,
|
||||
pointDilation
|
||||
);
|
||||
if (renderCache.flags !== newFlags) {
|
||||
renderCache.flags = newFlags;
|
||||
needsRepaint = true;
|
||||
flagBuffer({ data: newFlags, dimension: 1 });
|
||||
}
|
||||
|
||||
this.count = nObs;
|
||||
|
||||
if (needsRepaint) {
|
||||
this.renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
flagBuffer,
|
||||
camera,
|
||||
projectionTF
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasResized) {
|
||||
// If the window size has changed we want to recreate all SVGs
|
||||
projectionTF = createProjectionTF(viewport.width, viewport.height);
|
||||
stateChanges = {
|
||||
...stateChanges,
|
||||
...this.createToolSVG(),
|
||||
projectionTF,
|
||||
};
|
||||
} else if (
|
||||
(viewport.height && viewport.width && !toolSVG) ||
|
||||
selectionTool !== prevProps.selectionTool
|
||||
}
|
||||
|
||||
if (
|
||||
(viewport.height && viewport.width && !toolSVG) || // first time init
|
||||
hasResized || // window size has changed we want to recreate all SVGs
|
||||
selectionTool !== prevProps.selectionTool || // change of selection tool
|
||||
prevProps.graphInteractionMode !== graphInteractionMode // lasso/zoom mode is switched
|
||||
) {
|
||||
// first time or change of selection tool
|
||||
stateChanges = { ...stateChanges, ...this.createToolSVG() };
|
||||
} else if (prevProps.graphInteractionMode !== graphInteractionMode) {
|
||||
// If lasso/zoom is switched
|
||||
stateChanges = {
|
||||
...stateChanges,
|
||||
...this.createToolSVG(),
|
||||
@@ -367,6 +288,13 @@ class Graph extends React.Component {
|
||||
window.removeEventListener("resize", this.handleResize);
|
||||
}
|
||||
|
||||
setReglCanvas = (canvas) => {
|
||||
this.reglCanvas = canvas;
|
||||
this.setState({
|
||||
...Graph.createReglState(canvas),
|
||||
});
|
||||
};
|
||||
|
||||
handleResize = () => {
|
||||
const { state } = this.state;
|
||||
const viewport = this.getViewportDimensions();
|
||||
@@ -400,11 +328,13 @@ class Graph extends React.Component {
|
||||
Called from componentDidUpdate. Create the tool SVG, and return any
|
||||
state changes that should be passed to setState().
|
||||
*/
|
||||
const { viewport, selectionTool, graphInteractionMode } = this.props;
|
||||
const { selectionTool, graphInteractionMode } = this.props;
|
||||
const { viewport } = this.state;
|
||||
|
||||
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */
|
||||
|
||||
d3.select("#lasso-layer").selectAll(".lasso-group").remove();
|
||||
const lasso = d3.select("#lasso-layer");
|
||||
if (lasso.empty()) return {}; // still initializing
|
||||
lasso.selectAll(".lasso-group").remove();
|
||||
|
||||
// Don't render or recreate toolSVG if currently in zoom mode
|
||||
if (graphInteractionMode !== "select") {
|
||||
@@ -440,6 +370,88 @@ class Graph extends React.Component {
|
||||
return { toolSVG: newToolSVG, tool, container };
|
||||
};
|
||||
|
||||
fetchAsyncProps = async (props) => {
|
||||
const {
|
||||
annoMatrix,
|
||||
colors: colorsProp,
|
||||
layoutChoice,
|
||||
crossfilter,
|
||||
pointDilation,
|
||||
viewport,
|
||||
} = props.watchProps;
|
||||
const { modelTF } = this.state;
|
||||
|
||||
const [layoutDf, colorDf, pointDilationDf] = await this.fetchData(
|
||||
annoMatrix,
|
||||
layoutChoice,
|
||||
colorsProp,
|
||||
pointDilation
|
||||
);
|
||||
const { currentDimNames } = layoutChoice;
|
||||
const X = layoutDf.col(currentDimNames[0]).asArray();
|
||||
const Y = layoutDf.col(currentDimNames[1]).asArray();
|
||||
const positions = this.computePointPositions(X, Y, modelTF);
|
||||
|
||||
const colorTable = this.updateColorTable(colorsProp, colorDf);
|
||||
const colors = this.computePointColors(colorTable.rgb);
|
||||
|
||||
const { colorAccessor } = colorsProp;
|
||||
const colorByData = colorDf?.col(colorAccessor)?.asArray();
|
||||
const {
|
||||
metadataField: pointDilationCategory,
|
||||
categoryField: pointDilationLabel,
|
||||
} = pointDilation;
|
||||
const pointDilationData = pointDilationDf
|
||||
?.col(pointDilationCategory)
|
||||
?.asArray();
|
||||
const flags = this.computePointFlags(
|
||||
crossfilter,
|
||||
colorByData,
|
||||
pointDilationData,
|
||||
pointDilationLabel
|
||||
);
|
||||
|
||||
const { width, height } = viewport;
|
||||
return {
|
||||
positions,
|
||||
colors,
|
||||
flags,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
async fetchData(annoMatrix, layoutChoice, colors, pointDilation) {
|
||||
/*
|
||||
fetch all data needed. Includes:
|
||||
- the color by dataframe
|
||||
- the layout dataframe
|
||||
- the point dilation dataframe
|
||||
*/
|
||||
const { metadataField: pointDilationAccessor } = pointDilation;
|
||||
|
||||
const promises = [];
|
||||
// layout
|
||||
promises.push(annoMatrix.fetch("emb", layoutChoice.current));
|
||||
|
||||
// color
|
||||
const query = this.createColorByQuery(colors);
|
||||
if (query) {
|
||||
promises.push(annoMatrix.fetch(...query));
|
||||
} else {
|
||||
promises.push(Promise.resolve(null));
|
||||
}
|
||||
|
||||
// point highlighting
|
||||
if (pointDilationAccessor) {
|
||||
promises.push(annoMatrix.fetch("obs", pointDilationAccessor));
|
||||
} else {
|
||||
promises.push(Promise.resolve(null));
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
brushToolUpdate(tool, container) {
|
||||
/*
|
||||
this is called from componentDidUpdate(), so be very careful using
|
||||
@@ -571,17 +583,22 @@ class Graph extends React.Component {
|
||||
// ignore programatically generated events
|
||||
if (d3.event.sourceEvent === null || !d3.event.selection) return;
|
||||
|
||||
const { dispatch } = this.props;
|
||||
const { dispatch, layoutChoice } = this.props;
|
||||
const s = d3.event.selection;
|
||||
const brushCoords = {
|
||||
northwest: this.mapScreenToPoint([s[0][0], s[0][1]]),
|
||||
southeast: this.mapScreenToPoint([s[1][0], s[1][1]]),
|
||||
};
|
||||
|
||||
dispatch({
|
||||
type: "graph brush change",
|
||||
brushCoords,
|
||||
});
|
||||
const northwest = this.mapScreenToPoint(s[0]);
|
||||
const southeast = this.mapScreenToPoint(s[1]);
|
||||
const [minX, maxY] = northwest;
|
||||
const [maxX, minY] = southeast;
|
||||
dispatch(
|
||||
actions.graphBrushChangeAction(layoutChoice.current, {
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
northwest,
|
||||
southeast,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
handleBrushStartAction() {
|
||||
@@ -589,7 +606,7 @@ class Graph extends React.Component {
|
||||
if (!d3.event.sourceEvent) return;
|
||||
|
||||
const { dispatch } = this.props;
|
||||
dispatch({ type: "graph brush start" });
|
||||
dispatch(actions.graphBrushStartAction());
|
||||
}
|
||||
|
||||
handleBrushEndAction() {
|
||||
@@ -600,65 +617,67 @@ class Graph extends React.Component {
|
||||
coordinates will be included if selection made, null
|
||||
if selection cleared.
|
||||
*/
|
||||
const { dispatch } = this.props;
|
||||
const { dispatch, layoutChoice } = this.props;
|
||||
const s = d3.event.selection;
|
||||
if (s) {
|
||||
const brushCoords = {
|
||||
northwest: this.mapScreenToPoint(s[0]),
|
||||
southeast: this.mapScreenToPoint(s[1]),
|
||||
};
|
||||
dispatch({
|
||||
type: "graph brush end",
|
||||
brushCoords,
|
||||
});
|
||||
const northwest = this.mapScreenToPoint(s[0]);
|
||||
const southeast = this.mapScreenToPoint(s[1]);
|
||||
const [minX, maxY] = northwest;
|
||||
const [maxX, minY] = southeast;
|
||||
dispatch(
|
||||
actions.graphBrushEndAction(layoutChoice.current, {
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
northwest,
|
||||
southeast,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch({
|
||||
type: "graph brush deselect",
|
||||
});
|
||||
dispatch(actions.graphBrushDeselectAction(layoutChoice.current));
|
||||
}
|
||||
}
|
||||
|
||||
handleBrushDeselectAction() {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "graph brush deselect",
|
||||
});
|
||||
const { dispatch, layoutChoice } = this.props;
|
||||
dispatch(actions.graphBrushDeselectAction(layoutChoice.current));
|
||||
}
|
||||
|
||||
handleLassoStart() {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "graph lasso start",
|
||||
});
|
||||
const { dispatch, layoutChoice } = this.props;
|
||||
dispatch(actions.graphLassoStartAction(layoutChoice.current));
|
||||
}
|
||||
|
||||
// when a lasso is completed, filter to the points within the lasso polygon
|
||||
handleLassoEnd(polygon) {
|
||||
const minimumPolygonArea = 10;
|
||||
const { dispatch } = this.props;
|
||||
const { dispatch, layoutChoice } = this.props;
|
||||
|
||||
if (
|
||||
polygon.length < 3 ||
|
||||
Math.abs(d3.polygonArea(polygon)) < minimumPolygonArea
|
||||
) {
|
||||
// if less than three points, or super small area, treat as a clear selection.
|
||||
dispatch({ type: "graph lasso deselect" });
|
||||
dispatch(actions.graphLassoDeselectAction(layoutChoice.current));
|
||||
} else {
|
||||
dispatch({
|
||||
type: "graph lasso end",
|
||||
polygon: polygon.map((xy) => this.mapScreenToPoint(xy)), // transform the polygon
|
||||
});
|
||||
dispatch(
|
||||
actions.graphLassoEndAction(
|
||||
layoutChoice.current,
|
||||
polygon.map((xy) => this.mapScreenToPoint(xy))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleLassoCancel() {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({ type: "graph lasso cancel" });
|
||||
const { dispatch, layoutChoice } = this.props;
|
||||
dispatch(actions.graphLassoCancelAction(layoutChoice.current));
|
||||
}
|
||||
|
||||
handleLassoDeselectAction() {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({ type: "graph lasso deselect" });
|
||||
const { dispatch, layoutChoice } = this.props;
|
||||
dispatch(actions.graphLassoDeselectAction(layoutChoice.current));
|
||||
}
|
||||
|
||||
handleDeselectAction() {
|
||||
@@ -675,38 +694,6 @@ class Graph extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
flagBuffer,
|
||||
camera,
|
||||
projectionTF
|
||||
) {
|
||||
const { universe } = this.props;
|
||||
if (!this.reglCanvas || !universe) return;
|
||||
const cameraTF = camera.view();
|
||||
const projView = mat3.multiply(mat3.create(), projectionTF, cameraTF);
|
||||
const { width, height } = this.reglCanvas;
|
||||
regl.poll();
|
||||
regl.clear({
|
||||
depth: 1,
|
||||
color: [1, 1, 1, 1],
|
||||
});
|
||||
drawPoints({
|
||||
distance: camera.distance(),
|
||||
color: colorBuffer,
|
||||
position: pointBuffer,
|
||||
flag: flagBuffer,
|
||||
count: this.count,
|
||||
projView,
|
||||
nPoints: universe.nObs,
|
||||
minViewportDimension: Math.min(width, height),
|
||||
});
|
||||
regl._gl.flush();
|
||||
}
|
||||
|
||||
renderCanvas = renderThrottle(() => {
|
||||
const {
|
||||
regl,
|
||||
@@ -728,9 +715,92 @@ class Graph extends React.Component {
|
||||
);
|
||||
});
|
||||
|
||||
updateReglAndRender(asyncProps) {
|
||||
const { positions, colors, flags } = asyncProps;
|
||||
this.cachedAsyncProps = asyncProps;
|
||||
const { pointBuffer, colorBuffer, flagBuffer } = this.state;
|
||||
pointBuffer({ data: positions, dimension: 2 });
|
||||
colorBuffer({ data: colors, dimension: 3 });
|
||||
flagBuffer({ data: flags, dimension: 1 });
|
||||
this.renderCanvas();
|
||||
}
|
||||
|
||||
updateColorTable(colors, colorDf) {
|
||||
const { annoMatrix } = this.props;
|
||||
const { schema } = annoMatrix;
|
||||
|
||||
/* update color table state */
|
||||
if (!colors || !colorDf) {
|
||||
return createColorTable(
|
||||
null, // default mode
|
||||
null,
|
||||
null,
|
||||
schema,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
const { colorAccessor, userColors, colorMode } = colors;
|
||||
return createColorTable(
|
||||
colorMode,
|
||||
colorAccessor,
|
||||
colorDf,
|
||||
schema,
|
||||
userColors
|
||||
);
|
||||
}
|
||||
|
||||
createColorByQuery(colors) {
|
||||
const { annoMatrix } = this.props;
|
||||
const { schema } = annoMatrix;
|
||||
const { colorMode, colorAccessor } = colors;
|
||||
return createColorQuery(colorMode, colorAccessor, schema);
|
||||
}
|
||||
|
||||
renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
flagBuffer,
|
||||
camera,
|
||||
projectionTF
|
||||
) {
|
||||
const { annoMatrix } = this.props;
|
||||
if (!this.reglCanvas || !annoMatrix) return;
|
||||
|
||||
const { schema } = annoMatrix;
|
||||
const cameraTF = camera.view();
|
||||
const projView = mat3.multiply(mat3.create(), projectionTF, cameraTF);
|
||||
const { width, height } = this.reglCanvas;
|
||||
regl.poll();
|
||||
regl.clear({
|
||||
depth: 1,
|
||||
color: [1, 1, 1, 1],
|
||||
});
|
||||
drawPoints({
|
||||
distance: camera.distance(),
|
||||
color: colorBuffer,
|
||||
position: pointBuffer,
|
||||
flag: flagBuffer,
|
||||
count: annoMatrix.nObs,
|
||||
projView,
|
||||
nPoints: schema.dataframe.nObs,
|
||||
minViewportDimension: Math.min(width, height),
|
||||
});
|
||||
regl._gl.flush();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { graphInteractionMode } = this.props;
|
||||
const { modelTF, projectionTF, camera, viewport } = this.state;
|
||||
const {
|
||||
graphInteractionMode,
|
||||
annoMatrix,
|
||||
colors,
|
||||
layoutChoice,
|
||||
pointDilation,
|
||||
crossfilter,
|
||||
} = this.props;
|
||||
const { modelTF, projectionTF, camera, viewport, regl } = this.state;
|
||||
const cameraTF = camera?.view()?.slice();
|
||||
|
||||
return (
|
||||
@@ -781,18 +851,65 @@ class Graph extends React.Component {
|
||||
}}
|
||||
className="graph-canvas"
|
||||
data-testid="layout-graph"
|
||||
ref={(canvas) => {
|
||||
this.reglCanvas = canvas;
|
||||
}}
|
||||
ref={this.setReglCanvas}
|
||||
onMouseDown={this.handleCanvasEvent}
|
||||
onMouseUp={this.handleCanvasEvent}
|
||||
onMouseMove={this.handleCanvasEvent}
|
||||
onDoubleClick={this.handleCanvasEvent}
|
||||
onWheel={this.handleCanvasEvent}
|
||||
/>
|
||||
|
||||
<Async
|
||||
watchFn={Graph.watchAsync}
|
||||
promiseFn={this.fetchAsyncProps}
|
||||
watchProps={{
|
||||
annoMatrix,
|
||||
colors,
|
||||
layoutChoice,
|
||||
pointDilation,
|
||||
crossfilter,
|
||||
viewport,
|
||||
}}
|
||||
>
|
||||
<Async.Pending initial>Embedding loading...</Async.Pending>
|
||||
<Async.Rejected>
|
||||
{(error) => (
|
||||
<ErrorLoading
|
||||
displayName={layoutChoice.current}
|
||||
error={error}
|
||||
width={viewport.width}
|
||||
height={viewport.height}
|
||||
/>
|
||||
)}
|
||||
</Async.Rejected>
|
||||
<Async.Fulfilled>
|
||||
{(asyncProps) => {
|
||||
if (regl && !shallowEqual(asyncProps, this.cachedAsyncProps)) {
|
||||
this.updateReglAndRender(asyncProps);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Async.Fulfilled>
|
||||
</Async>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ErrorLoading = ({ displayName, error, width, height }) => {
|
||||
console.log(error); // log to console as this is an unepected error
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
fontWeight: 500,
|
||||
top: height / 2,
|
||||
left: globals.leftSidebarWidth + width / 2 - 50,
|
||||
}}
|
||||
>
|
||||
<span>{`Failure loading ${displayName}`}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Graph;
|
||||
|
||||
@@ -1,113 +1,217 @@
|
||||
import React, { PureComponent } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { connect, shallowEqual } from "react-redux";
|
||||
import Async from "react-async";
|
||||
|
||||
import { categoryLabelDisplayStringLongLength } from "../../../globals";
|
||||
import calcCentroid from "../../../util/centroid";
|
||||
import { createColorQuery } from "../../../util/stateManager/colorHelpers";
|
||||
|
||||
export default
|
||||
@connect((state) => ({
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
annoMatrix: state.annoMatrix,
|
||||
colors: state.colors,
|
||||
layoutChoice: state.layoutChoice,
|
||||
dilatedValue: state.pointDilation.categoryField,
|
||||
labels: state.centroidLabels.labels,
|
||||
categoricalSelection: state.categoricalSelection,
|
||||
showLabels: state.centroidLabels?.showLabels,
|
||||
}))
|
||||
class CentroidLabels extends PureComponent {
|
||||
// Check to see if centroids have either just been displayed or removed from the overlay
|
||||
static watchAsync(props, prevProps) {
|
||||
return !shallowEqual(props.watchProps, prevProps.watchProps);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { labels, overlayToggled } = this.props;
|
||||
const prevSize = prevProps.labels.size;
|
||||
const { size } = labels;
|
||||
fetchAsyncProps = async (props) => {
|
||||
const {
|
||||
annoMatrix,
|
||||
colors,
|
||||
layoutChoice,
|
||||
categoricalSelection,
|
||||
showLabels,
|
||||
} = props.watchProps;
|
||||
const { schema } = annoMatrix;
|
||||
const { colorAccessor } = colors;
|
||||
|
||||
const displayChangeOff = prevSize > 0 && size === undefined;
|
||||
const displayChangeOn = prevSize === undefined && size > 0;
|
||||
|
||||
if (displayChangeOn || displayChangeOff) {
|
||||
// Notify overlay layer of display change
|
||||
overlayToggled("centroidLabels", displayChangeOn);
|
||||
const [layoutDf, colorDf] = await this.fetchData();
|
||||
let labels;
|
||||
if (colorDf) {
|
||||
labels = calcCentroid(
|
||||
schema,
|
||||
colorAccessor,
|
||||
colorDf,
|
||||
layoutChoice,
|
||||
layoutDf
|
||||
);
|
||||
} else {
|
||||
labels = new Map();
|
||||
}
|
||||
|
||||
const { overlaySetShowing } = this.props;
|
||||
overlaySetShowing("centroidLabels", showLabels && labels.size > 0);
|
||||
|
||||
return {
|
||||
labels,
|
||||
colorAccessor,
|
||||
category: categoricalSelection[colorAccessor],
|
||||
};
|
||||
};
|
||||
|
||||
handleMouseEnter = (e, colorAccessor, label) => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "category value mouse hover start",
|
||||
metadataField: colorAccessor,
|
||||
categoryField: label,
|
||||
});
|
||||
};
|
||||
|
||||
handleMouseOut = (e, colorAccessor, label) => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "category value mouse hover end",
|
||||
metadataField: colorAccessor,
|
||||
categoryField: label,
|
||||
});
|
||||
};
|
||||
|
||||
colorByQuery() {
|
||||
const { annoMatrix, colors } = this.props;
|
||||
const { schema } = annoMatrix;
|
||||
const { colorMode, colorAccessor } = colors;
|
||||
return createColorQuery(colorMode, colorAccessor, schema);
|
||||
}
|
||||
|
||||
async fetchData() {
|
||||
const { annoMatrix, layoutChoice } = this.props;
|
||||
// fetch all data we need: layout, category
|
||||
const promises = [];
|
||||
// layout
|
||||
promises.push(annoMatrix.fetch("emb", layoutChoice.current));
|
||||
// category to label - we ONLY label on obs, never on X, etc.
|
||||
const query = this.colorByQuery();
|
||||
if (query && query[0] === "obs") {
|
||||
promises.push(annoMatrix.fetch(...query));
|
||||
} else {
|
||||
promises.push(Promise.resolve(null));
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
labels,
|
||||
inverseTransform,
|
||||
dilatedValue,
|
||||
dispatch,
|
||||
colorAccessor,
|
||||
categoricalSelection,
|
||||
showLabels,
|
||||
colors,
|
||||
annoMatrix,
|
||||
layoutChoice,
|
||||
} = this.props;
|
||||
|
||||
if (!colorAccessor || labels.size === undefined || labels.size === 0)
|
||||
return null;
|
||||
return (
|
||||
<Async
|
||||
watchFn={CentroidLabels.watchAsync}
|
||||
promiseFn={this.fetchAsyncProps}
|
||||
watchProps={{
|
||||
annoMatrix,
|
||||
colors,
|
||||
layoutChoice,
|
||||
categoricalSelection,
|
||||
dilatedValue,
|
||||
showLabels,
|
||||
}}
|
||||
>
|
||||
<Async.Fulfilled>
|
||||
{(asyncProps) => {
|
||||
if (!showLabels) return null;
|
||||
|
||||
const category = categoricalSelection[colorAccessor];
|
||||
const labelSVGS = [];
|
||||
const deselectOpacity = 0.375;
|
||||
const { category, colorAccessor, labels } = asyncProps;
|
||||
|
||||
const labelSVGS = [];
|
||||
let fontSize = "15px";
|
||||
let fontWeight = null;
|
||||
const deselectOpacity = 0.375;
|
||||
labels.forEach((coords, label) => {
|
||||
fontSize = "15px";
|
||||
fontWeight = null;
|
||||
if (label === dilatedValue) {
|
||||
fontSize = "18px";
|
||||
fontWeight = "800";
|
||||
}
|
||||
labels.forEach((coords, label) => {
|
||||
const selected = category.get(label) ?? true;
|
||||
|
||||
const selected = category.get(label) ?? true;
|
||||
// Mirror LSB middle truncation
|
||||
let displayLabel = label;
|
||||
if (displayLabel.length > categoryLabelDisplayStringLongLength) {
|
||||
displayLabel = `${label.slice(
|
||||
0,
|
||||
categoryLabelDisplayStringLongLength / 2
|
||||
)}…${label.slice(-categoryLabelDisplayStringLongLength / 2)}`;
|
||||
}
|
||||
|
||||
// Mirror LSB middle truncation
|
||||
let displayLabel = label;
|
||||
if (displayLabel.length > categoryLabelDisplayStringLongLength) {
|
||||
displayLabel = `${label.slice(
|
||||
0,
|
||||
categoryLabelDisplayStringLongLength / 2
|
||||
)}…${label.slice(-categoryLabelDisplayStringLongLength / 2)}`;
|
||||
}
|
||||
labelSVGS.push(
|
||||
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events -- the mouse actions for centroid labels do not have a screen reader alternative
|
||||
<Label
|
||||
key={label} // eslint-disable-line react/no-array-index-key --- label is not an index, eslint is confused
|
||||
label={label}
|
||||
dilatedValue={dilatedValue}
|
||||
coords={coords}
|
||||
inverseTransform={inverseTransform}
|
||||
opactity={selected ? 1 : deselectOpacity}
|
||||
colorAccessor={colorAccessor}
|
||||
displayLabel={displayLabel}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseOut={this.handleMouseOut}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
labelSVGS.push(
|
||||
<g
|
||||
// eslint-disable-next-line react/no-array-index-key --- label is unique and consistent
|
||||
key={label}
|
||||
className="centroid-label"
|
||||
transform={`translate(${coords[0]}, ${coords[1]})`}
|
||||
data-testclass="centroid-label"
|
||||
data-testid={`${label}-centroid-label`}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events --- the mouse actions for centroid labels do not have a screen reader alternative*/}
|
||||
<text
|
||||
transform={inverseTransform}
|
||||
textAnchor="middle"
|
||||
data-label={label}
|
||||
style={{
|
||||
fontSize,
|
||||
fontWeight,
|
||||
fill: "black",
|
||||
userSelect: "none",
|
||||
opacity: selected ? 1 : deselectOpacity,
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
dispatch({
|
||||
type: "category value mouse hover start",
|
||||
metadataField: colorAccessor,
|
||||
categoryField: e.target.getAttribute("data-label"),
|
||||
})
|
||||
}
|
||||
onMouseOut={(e) =>
|
||||
dispatch({
|
||||
type: "category value mouse hover end",
|
||||
metadataField: colorAccessor,
|
||||
categoryField: e.target.getAttribute("data-label"),
|
||||
})
|
||||
}
|
||||
pointerEvents="visiblePainted"
|
||||
>
|
||||
{displayLabel}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
});
|
||||
|
||||
return <>{labelSVGS}</>;
|
||||
return <>{labelSVGS}</>;
|
||||
}}
|
||||
</Async.Fulfilled>
|
||||
</Async>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Label = ({
|
||||
label,
|
||||
dilatedValue,
|
||||
coords,
|
||||
inverseTransform,
|
||||
opacity,
|
||||
colorAccessor,
|
||||
displayLabel,
|
||||
onMouseEnter,
|
||||
onMouseOut,
|
||||
}) => {
|
||||
/*
|
||||
Render a label at a given coordinate.
|
||||
*/
|
||||
let fontSize = "15px";
|
||||
let fontWeight = null;
|
||||
if (label === dilatedValue) {
|
||||
fontSize = "18px";
|
||||
fontWeight = "800";
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
key={label}
|
||||
className="centroid-label"
|
||||
transform={`translate(${coords[0]}, ${coords[1]})`}
|
||||
data-testclass="centroid-label"
|
||||
data-testid={`${label}-centroid-label`}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events --- the mouse actions for centroid labels do not have a screen reader alternative*/}
|
||||
<text
|
||||
transform={inverseTransform}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize,
|
||||
fontWeight,
|
||||
fill: "black",
|
||||
userSelect: "none",
|
||||
opacity: { opacity },
|
||||
}}
|
||||
onMouseEnter={(e) => onMouseEnter(e, colorAccessor, label)}
|
||||
onMouseOut={(e) => onMouseOut(e, colorAccessor, label)}
|
||||
pointerEvents="visiblePainted"
|
||||
>
|
||||
{displayLabel}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ export default class GraphOverlayLayer extends PureComponent {
|
||||
};
|
||||
|
||||
// This is passed to all children, should be called when an overlay's display state is toggled along with the overlay name and its new display state in boolean form
|
||||
overlayToggled = (overlay, displaying) => {
|
||||
overlaySetShowing = (overlay, displaying) => {
|
||||
this.setState((state) => {
|
||||
return { ...state, display: { ...state.display, [overlay]: displaying } };
|
||||
});
|
||||
@@ -67,7 +67,7 @@ export default class GraphOverlayLayer extends PureComponent {
|
||||
const newChildren = React.Children.map(children, (child) =>
|
||||
cloneElement(child, {
|
||||
inverseTransform,
|
||||
overlayToggled: this.overlayToggled,
|
||||
overlaySetShowing: this.overlaySetShowing,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export default (
|
||||
viewport
|
||||
) => {
|
||||
const svg = d3.select("#graph-wrapper").select("#lasso-layer");
|
||||
if (svg.empty()) return {};
|
||||
|
||||
if (selectionToolType === "brush") {
|
||||
const brush = d3
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import React from "react";
|
||||
import { AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||
import { connect } from "react-redux";
|
||||
import { World } from "../../util/stateManager";
|
||||
import { tooltipHoverOpenDelay } from "../../globals";
|
||||
|
||||
@connect()
|
||||
@@ -15,12 +14,8 @@ class CellSetButton extends React.PureComponent {
|
||||
eitherCellSetOneOrTwo,
|
||||
} = this.props;
|
||||
|
||||
// Reducer and components assume that value will be null if
|
||||
// no selection made. World..getSelectedByIndex() returns a
|
||||
// zero length TypedArray when nothing is selected.
|
||||
let set = World.getSelectedByIndex(crossfilter);
|
||||
let set = crossfilter.allSelectedLabels();
|
||||
if (set.length === 0) set = null;
|
||||
|
||||
if (!differential.diffExp) {
|
||||
/* diffexp needs to be cleared before we store a new set */
|
||||
dispatch({
|
||||
|
||||
@@ -9,7 +9,7 @@ import CellSetButton from "./cellSetButtons";
|
||||
|
||||
@connect((state) => ({
|
||||
config: state.config,
|
||||
crossfilter: state.crossfilter,
|
||||
crossfilter: state.obsCrossfilter,
|
||||
differential: state.differential,
|
||||
celllist1: state.differential?.celllist1,
|
||||
celllist2: state.differential?.celllist2,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AnchorButton,
|
||||
ButtonGroup,
|
||||
Popover,
|
||||
Button,
|
||||
@@ -12,26 +11,25 @@ import {
|
||||
import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
import styles from "./menubar.css";
|
||||
import { World } from "../../util/stateManager";
|
||||
import actions from "../../actions";
|
||||
|
||||
@connect((state) => ({
|
||||
universe: state.universe,
|
||||
world: state.world,
|
||||
layoutChoice: state.layoutChoice,
|
||||
reembedController: state.reembedController,
|
||||
enableReembedding: state.config?.parameters?.["enable-reembedding"] ?? false,
|
||||
// disabled temporarily. TODO - issue #1606
|
||||
// reembedController: state.reembedController,
|
||||
// enableReembedding: state.config?.parameters?.["enable-reembedding"] ?? false,
|
||||
enableReembedding: false,
|
||||
}))
|
||||
class Embedding extends React.PureComponent {
|
||||
handleLayoutChoiceChange = (e) => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch({
|
||||
type: "set layout choice",
|
||||
layoutChoice: e.currentTarget.value,
|
||||
});
|
||||
dispatch(actions.layoutChoiceAction(e.currentTarget.value));
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this -- temporary disable
|
||||
renderReembedding() {
|
||||
return null;
|
||||
/* disabled pending rewrite. TODO - issue #1606
|
||||
const {
|
||||
enableReembedding,
|
||||
world,
|
||||
@@ -63,6 +61,7 @@ class Embedding extends React.PureComponent {
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
*/
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { ButtonGroup, AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||
@@ -14,13 +13,12 @@ import UndoRedoReset from "./undoRedo";
|
||||
import DiffexpButtons from "./diffexpButtons";
|
||||
|
||||
@connect((state) => ({
|
||||
universe: state.universe,
|
||||
world: state.world,
|
||||
crossfilter: state.crossfilter,
|
||||
annoMatrix: state.annoMatrix,
|
||||
crossfilter: state.obsCrossfilter,
|
||||
differential: state.differential,
|
||||
graphInteractionMode: state.controls.graphInteractionMode,
|
||||
clipPercentileMin: Math.round(100 * (state.world?.clipQuantiles?.min ?? 0)),
|
||||
clipPercentileMax: Math.round(100 * (state.world?.clipQuantiles?.max ?? 1)),
|
||||
clipPercentileMin: Math.round(100 * (state.annoMatrix?.clipRange?.[0] ?? 0)),
|
||||
clipPercentileMax: Math.round(100 * (state.annoMatrix?.clipRange?.[1] ?? 1)),
|
||||
userDefinedGenes: state.controls.userDefinedGenes,
|
||||
diffexpGenes: state.controls.diffexpGenes,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
@@ -78,10 +76,10 @@ class MenuBar extends React.Component {
|
||||
const { pendingClipPercentiles } = this.state;
|
||||
const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin;
|
||||
const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax;
|
||||
|
||||
const { world } = this.props;
|
||||
const currentClipMin = 100 * world?.clipQuantiles?.min;
|
||||
const currentClipMax = 100 * world?.clipQuantiles?.max;
|
||||
const {
|
||||
clipPercentileMin: currentClipMin,
|
||||
clipPercentileMax: currentClipMax,
|
||||
} = this.props;
|
||||
|
||||
// if you change this test, be careful with logic around
|
||||
// comparisons between undefined / NaN handling.
|
||||
@@ -150,10 +148,7 @@ class MenuBar extends React.Component {
|
||||
const { clipPercentileMin, clipPercentileMax } = pendingClipPercentiles;
|
||||
const min = clipPercentileMin / 100;
|
||||
const max = clipPercentileMax / 100;
|
||||
dispatch({
|
||||
type: "set clip quantiles",
|
||||
clipQuantiles: { min, max },
|
||||
});
|
||||
dispatch(actions.clipAction(min, max));
|
||||
};
|
||||
|
||||
handleClipOpening = () => {
|
||||
@@ -178,15 +173,15 @@ class MenuBar extends React.Component {
|
||||
|
||||
subsetPossible = () => {
|
||||
const { crossfilter } = this.props;
|
||||
const count = crossfilter.countSelected();
|
||||
return (
|
||||
crossfilter.countSelected() !== 0 &&
|
||||
crossfilter.countSelected() !== crossfilter.size()
|
||||
count !== 0 && count !== crossfilter.size() // ie, not all are selected
|
||||
);
|
||||
};
|
||||
|
||||
subsetResetPossible = () => {
|
||||
const { world, universe } = this.props;
|
||||
return world.nObs !== universe.nObs;
|
||||
const { annoMatrix } = this.props;
|
||||
return annoMatrix.nObs !== annoMatrix.schema.dataframe.nObs;
|
||||
};
|
||||
|
||||
render() {
|
||||
@@ -317,12 +312,10 @@ class MenuBar extends React.Component {
|
||||
subsetPossible={this.subsetPossible()}
|
||||
subsetResetPossible={this.subsetResetPossible()}
|
||||
handleSubset={() => {
|
||||
dispatch(actions.setWorldToSelection());
|
||||
dispatch({ type: "increment graph render counter" });
|
||||
dispatch(actions.subsetAction());
|
||||
}}
|
||||
handleSubsetReset={() => {
|
||||
dispatch(actions.resetWorldToUniverse());
|
||||
dispatch({ type: "increment graph render counter" });
|
||||
dispatch(actions.resetSubsetAction());
|
||||
}}
|
||||
/>
|
||||
{disableDiffexp ? null : <DiffexpButtons />}
|
||||
|
||||
@@ -14,6 +14,9 @@ export default class MiniHistogram extends React.PureComponent {
|
||||
|
||||
drawHistogram = () => {
|
||||
const { xScale, yScale, bins, width, height } = this.props;
|
||||
|
||||
if (!bins) return;
|
||||
|
||||
const ctx = this.canvasRef.current.getContext("2d");
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
@@ -12,12 +12,16 @@ export default class MiniStackedBar extends React.PureComponent {
|
||||
domainValues,
|
||||
scale,
|
||||
domain,
|
||||
colorScale,
|
||||
colorTable,
|
||||
occupancy,
|
||||
width,
|
||||
height,
|
||||
} = this.props;
|
||||
|
||||
if (!colorTable || !domainValues) return;
|
||||
|
||||
const { scale: colorScale } = colorTable;
|
||||
|
||||
const ctx = this.canvasRef?.current.getContext("2d");
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { connect, shallowEqual } from "react-redux";
|
||||
import { Button, ButtonGroup } from "@blueprintjs/core";
|
||||
import _regl from "regl";
|
||||
import * as d3 from "d3";
|
||||
import { mat3 } from "gl-matrix";
|
||||
import memoize from "memoize-one";
|
||||
import { isTypedArray } from "../../util/typeHelpers";
|
||||
import Async from "react-async";
|
||||
|
||||
import * as globals from "../../globals";
|
||||
import setupScatterplot from "./setupScatterplot";
|
||||
import styles from "./scatterplot.css";
|
||||
import _drawPoints from "./drawPointsRegl";
|
||||
import { margin, width, height } from "./util";
|
||||
import finiteExtent from "../../util/finiteExtent";
|
||||
import {
|
||||
createColorTable,
|
||||
createColorQuery,
|
||||
} from "../../util/stateManager/colorHelpers";
|
||||
import renderThrottle from "../../util/renderThrottle";
|
||||
|
||||
const flagSelected = 1;
|
||||
const flagNaN = 2;
|
||||
const flagHighlight = 4;
|
||||
|
||||
function createProjectionTF(viewportWidth, viewportHeight) {
|
||||
/*
|
||||
@@ -22,40 +29,58 @@ function createProjectionTF(viewportWidth, viewportHeight) {
|
||||
return mat3.projection(m, viewportWidth, viewportHeight);
|
||||
}
|
||||
|
||||
function getScale(col, rangeMin, rangeMax) {
|
||||
if (!col) return null;
|
||||
const { min, max } = col.summarize();
|
||||
return d3.scaleLinear().domain([min, max]).range([rangeMin, rangeMax]);
|
||||
}
|
||||
const getXScale = memoize(getScale);
|
||||
const getYScale = memoize(getScale);
|
||||
|
||||
@connect((state) => {
|
||||
const { world, crossfilter, universe } = state;
|
||||
const { obsCrossfilter: crossfilter } = state;
|
||||
const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls;
|
||||
const expressionX = scatterplotXXaccessor
|
||||
? world.varData.col(scatterplotXXaccessor)?.asArray()
|
||||
: null;
|
||||
const expressionY = scatterplotYYaccessor
|
||||
? world.varData.col(scatterplotYYaccessor)?.asArray()
|
||||
: null;
|
||||
|
||||
return {
|
||||
world,
|
||||
universe,
|
||||
|
||||
colorRGB: state.colors.rgb,
|
||||
colorScale: state.colors.scale,
|
||||
colorAccessor: state.colors.colorAccessor,
|
||||
|
||||
annoMatrix: state.annoMatrix,
|
||||
colors: state.colors,
|
||||
pointDilation: state.pointDilation,
|
||||
|
||||
// Accessors are var/gene names (strings)
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor,
|
||||
opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
|
||||
|
||||
differential: state.differential,
|
||||
|
||||
expressionX,
|
||||
expressionY,
|
||||
|
||||
crossfilter,
|
||||
};
|
||||
})
|
||||
class Scatterplot extends React.PureComponent {
|
||||
static createReglState(canvas) {
|
||||
/*
|
||||
Must be created for each canvas
|
||||
*/
|
||||
// setup canvas, webgl draw function and camera
|
||||
const regl = _regl(canvas);
|
||||
const drawPoints = _drawPoints(regl);
|
||||
|
||||
// preallocate webgl buffers
|
||||
const pointBuffer = regl.buffer();
|
||||
const colorBuffer = regl.buffer();
|
||||
const flagBuffer = regl.buffer();
|
||||
|
||||
return {
|
||||
regl,
|
||||
drawPoints,
|
||||
pointBuffer,
|
||||
colorBuffer,
|
||||
flagBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
static watchAsync(props, prevProps) {
|
||||
return !shallowEqual(props.watchProps, prevProps.watchProps);
|
||||
}
|
||||
|
||||
computePointPositions = memoize((X, Y, xScale, yScale) => {
|
||||
const positions = new Float32Array(2 * X.length);
|
||||
for (let i = 0, len = X.length; i < len; i += 1) {
|
||||
@@ -77,21 +102,31 @@ class Scatterplot extends React.PureComponent {
|
||||
});
|
||||
|
||||
computeSelectedFlags = memoize(
|
||||
(crossfilter, flagSelected, flagUnselected) => {
|
||||
(crossfilter, _flagSelected, _flagUnselected) => {
|
||||
const x = crossfilter.fillByIsSelected(
|
||||
new Float32Array(crossfilter.size()),
|
||||
flagSelected,
|
||||
flagUnselected
|
||||
_flagSelected,
|
||||
_flagUnselected
|
||||
);
|
||||
return x;
|
||||
}
|
||||
);
|
||||
|
||||
computePointFlags = memoize(
|
||||
(world, crossfilter, colorAccessor, pointDilation) => {
|
||||
const flagSelected = 1;
|
||||
const flagNaN = 2;
|
||||
const flagHighlight = 4;
|
||||
(crossfilter, colorByData, pointDilationData, pointDilationLabel) => {
|
||||
/*
|
||||
We communicate with the shader using three flags:
|
||||
- isNaN -- the value is a NaN. Only makes sense when we have a colorAccessor
|
||||
- isSelected -- the value is selected
|
||||
- isHightlighted -- the value is highlighted in the UI (orthogonal from selection highlighting)
|
||||
|
||||
Due to constraints in webgl vertex shader attributes, these are encoded in a float, "kinda"
|
||||
like bitmasks.
|
||||
|
||||
We also have separate code paths for generating flags for categorical and
|
||||
continuous metadata, as they rely on different tests, and some of the flags
|
||||
(eg, isNaN) are meaningless in the face of categorical metadata.
|
||||
*/
|
||||
|
||||
const flags = this.computeSelectedFlags(
|
||||
crossfilter,
|
||||
@@ -99,21 +134,11 @@ class Scatterplot extends React.PureComponent {
|
||||
0
|
||||
).slice();
|
||||
|
||||
const { metadataField, categoryField } = pointDilation;
|
||||
const highlightData = metadataField
|
||||
? world.obsAnnotations.col(metadataField)?.asArray()
|
||||
: null;
|
||||
const colorByColumn = colorAccessor
|
||||
? world.obsAnnotations.col(colorAccessor)?.asArray() ||
|
||||
world.varData.col(colorAccessor)?.asArray()
|
||||
: null;
|
||||
const colorByData =
|
||||
colorByColumn && isTypedArray(colorByColumn) ? colorByColumn : null;
|
||||
|
||||
if (colorByData || highlightData) {
|
||||
if (colorByData || pointDilationData) {
|
||||
for (let i = 0, len = flags.length; i < len; i += 1) {
|
||||
if (highlightData) {
|
||||
flags[i] += highlightData[i] === categoryField ? flagHighlight : 0;
|
||||
if (pointDilationData) {
|
||||
flags[i] +=
|
||||
pointDilationData[i] === pointDilationLabel ? flagHighlight : 0;
|
||||
}
|
||||
if (colorByData) {
|
||||
flags[i] += Number.isFinite(colorByData[i]) ? 0 : flagNaN;
|
||||
@@ -126,162 +151,48 @@ class Scatterplot extends React.PureComponent {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.count = 0;
|
||||
this.axes = false;
|
||||
this.renderCache = {
|
||||
positions: null,
|
||||
colors: null,
|
||||
flags: null,
|
||||
xScale: null,
|
||||
yScale: null,
|
||||
};
|
||||
this.reglCanvas = null;
|
||||
this.renderCache = null;
|
||||
this.state = {
|
||||
svg: null,
|
||||
regl: null,
|
||||
drawPoints: null,
|
||||
minimized: null,
|
||||
viewport: {
|
||||
height: null,
|
||||
width: null,
|
||||
},
|
||||
projectionTF: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { svg } = setupScatterplot(width, height, margin);
|
||||
let scales;
|
||||
const { expressionX, expressionY } = this.props;
|
||||
|
||||
if (svg && expressionX && expressionY) {
|
||||
scales = Scatterplot.setupScales(expressionX, expressionY);
|
||||
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
|
||||
this.renderCache = { ...this.renderCache, ...scales };
|
||||
}
|
||||
|
||||
const regl = _regl(this.reglCanvas);
|
||||
const drawPoints = _drawPoints(regl);
|
||||
|
||||
// Create render transform
|
||||
const projectionTF = createProjectionTF(
|
||||
this.reglCanvas.width,
|
||||
this.reglCanvas.height
|
||||
);
|
||||
|
||||
// preallocate buffers
|
||||
const pointBuffer = regl.buffer();
|
||||
const colorBuffer = regl.buffer();
|
||||
const flagBuffer = regl.buffer();
|
||||
|
||||
this.renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
flagBuffer,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
projectionTF
|
||||
);
|
||||
|
||||
window.addEventListener("resize", this.handleResize);
|
||||
const viewport = this.getViewportDimensions();
|
||||
|
||||
this.setState({
|
||||
regl,
|
||||
flagBuffer,
|
||||
pointBuffer,
|
||||
colorBuffer,
|
||||
svg,
|
||||
drawPoints,
|
||||
projectionTF,
|
||||
viewport,
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
world,
|
||||
crossfilter,
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor,
|
||||
expressionX,
|
||||
expressionY,
|
||||
colorRGB,
|
||||
colorAccessor,
|
||||
pointDilation,
|
||||
} = this.props;
|
||||
const {
|
||||
regl,
|
||||
pointBuffer,
|
||||
colorBuffer,
|
||||
flagBuffer,
|
||||
svg,
|
||||
drawPoints,
|
||||
projectionTF,
|
||||
} = this.state;
|
||||
|
||||
if (
|
||||
scatterplotXXaccessor !== prevProps.scatterplotXXaccessor ||
|
||||
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor ||
|
||||
world !== prevProps.world // shape or clip of world changed
|
||||
) {
|
||||
const scales = Scatterplot.setupScales(expressionX, expressionY);
|
||||
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
|
||||
this.renderCache = { ...this.renderCache, ...scales };
|
||||
}
|
||||
|
||||
if (world && regl) {
|
||||
const { renderCache } = this;
|
||||
const { xScale, yScale } = this.renderCache;
|
||||
let needsRepaint = false;
|
||||
|
||||
const newPositions = this.computePointPositions(
|
||||
expressionX,
|
||||
expressionY,
|
||||
xScale,
|
||||
yScale
|
||||
);
|
||||
if (renderCache.positions !== newPositions) {
|
||||
renderCache.positions = newPositions;
|
||||
pointBuffer({ data: renderCache.positions, dimension: 2 });
|
||||
needsRepaint = true;
|
||||
}
|
||||
|
||||
/* colors for each point */
|
||||
const newColors = this.computePointColors(colorRGB);
|
||||
if (renderCache.colors !== newColors) {
|
||||
renderCache.colors = newColors;
|
||||
colorBuffer({ data: renderCache.colors, dimension: 3 });
|
||||
needsRepaint = true;
|
||||
}
|
||||
|
||||
const newFlags = this.computePointFlags(
|
||||
world,
|
||||
crossfilter,
|
||||
colorAccessor,
|
||||
pointDilation
|
||||
);
|
||||
if (renderCache.flags !== newFlags) {
|
||||
renderCache.flags = newFlags;
|
||||
flagBuffer({ data: renderCache.flags, dimension: 1 });
|
||||
needsRepaint = true;
|
||||
}
|
||||
|
||||
this.count = expressionX.length;
|
||||
|
||||
if (needsRepaint) {
|
||||
this.renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
flagBuffer,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
projectionTF
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("resize", this.updateViewportDimensions);
|
||||
}
|
||||
|
||||
setReglCanvas = (canvas) => {
|
||||
this.reglCanvas = canvas;
|
||||
this.setState({
|
||||
...Scatterplot.createReglState(canvas),
|
||||
});
|
||||
};
|
||||
|
||||
getViewportDimensions = () => {
|
||||
return {
|
||||
viewport: {
|
||||
@@ -291,22 +202,6 @@ class Scatterplot extends React.PureComponent {
|
||||
};
|
||||
};
|
||||
|
||||
static setupScales(expressionX, expressionY) {
|
||||
const xScale = d3
|
||||
.scaleLinear()
|
||||
.domain(finiteExtent(expressionX))
|
||||
.range([0, width]);
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain(finiteExtent(expressionY))
|
||||
.range([height, 0]);
|
||||
|
||||
return {
|
||||
xScale,
|
||||
yScale,
|
||||
};
|
||||
}
|
||||
|
||||
handleResize = () => {
|
||||
const { state } = this.state;
|
||||
const viewport = this.getViewportDimensions();
|
||||
@@ -320,48 +215,167 @@ class Scatterplot extends React.PureComponent {
|
||||
this.setState(this.getViewportDimensions());
|
||||
};
|
||||
|
||||
drawAxesSVG(xScale, yScale, svg) {
|
||||
const { scatterplotYYaccessor, scatterplotXXaccessor } = this.props;
|
||||
svg.selectAll("*").remove();
|
||||
fetchAsyncProps = async (props) => {
|
||||
const {
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor,
|
||||
colors: colorsProp,
|
||||
crossfilter,
|
||||
pointDilation,
|
||||
} = props.watchProps;
|
||||
|
||||
// the axes are much cleaner and easier now. No need to rotate and orient
|
||||
// the axis, just call axisBottom, axisLeft etc.
|
||||
const xAxis = d3.axisBottom().ticks(7).scale(xScale);
|
||||
const [
|
||||
expressionXDf,
|
||||
expressionYDf,
|
||||
colorDf,
|
||||
pointDilationDf,
|
||||
] = await this.fetchData(
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor,
|
||||
colorsProp,
|
||||
pointDilation
|
||||
);
|
||||
const colorTable = this.updateColorTable(colorsProp, colorDf);
|
||||
|
||||
const yAxis = d3.axisLeft().ticks(7).scale(yScale);
|
||||
const xCol = expressionXDf.icol(0);
|
||||
const yCol = expressionYDf.icol(0);
|
||||
const xScale = getXScale(xCol, 0, width);
|
||||
const yScale = getYScale(yCol, height, 0);
|
||||
const positions = this.computePointPositions(
|
||||
xCol.asArray(),
|
||||
yCol.asArray(),
|
||||
xScale,
|
||||
yScale
|
||||
);
|
||||
|
||||
// adding axes is also simpler now, just translate x-axis to (0,height)
|
||||
// and it's alread defined to be a bottom axis.
|
||||
svg
|
||||
.append("g")
|
||||
.attr("transform", `translate(0,${height})`)
|
||||
.attr("class", "x axis")
|
||||
.call(xAxis);
|
||||
const colors = this.computePointColors(colorTable.rgb);
|
||||
|
||||
// y-axis is translated to (0,0)
|
||||
svg
|
||||
.append("g")
|
||||
.attr("transform", "translate(0,0)")
|
||||
.attr("class", "y axis")
|
||||
.call(yAxis);
|
||||
const { colorAccessor } = colorsProp;
|
||||
const colorByData = colorDf?.col(colorAccessor)?.asArray();
|
||||
const {
|
||||
metadataField: pointDilationCategory,
|
||||
categoryField: pointDilationLabel,
|
||||
} = pointDilation;
|
||||
const pointDilationData = pointDilationDf
|
||||
?.col(pointDilationCategory)
|
||||
?.asArray();
|
||||
const flags = this.computePointFlags(
|
||||
crossfilter,
|
||||
colorByData,
|
||||
pointDilationData,
|
||||
pointDilationLabel
|
||||
);
|
||||
|
||||
// adding label. For x-axis, it's at (10, 10), and for y-axis at (width, height-10).
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", 10)
|
||||
.attr("y", 10)
|
||||
.attr("class", "label")
|
||||
.style("font-style", "italic")
|
||||
.text(scatterplotYYaccessor);
|
||||
return {
|
||||
positions,
|
||||
colors,
|
||||
flags,
|
||||
width,
|
||||
height,
|
||||
xScale,
|
||||
yScale,
|
||||
};
|
||||
};
|
||||
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width)
|
||||
.attr("y", height - 10)
|
||||
.attr("text-anchor", "end")
|
||||
.attr("class", "label")
|
||||
.style("font-style", "italic")
|
||||
.text(scatterplotXXaccessor);
|
||||
createXQuery(geneName) {
|
||||
const { annoMatrix } = this.props;
|
||||
const { schema } = annoMatrix;
|
||||
const varIndex = schema?.annotations?.var?.index;
|
||||
if (!varIndex) return null;
|
||||
return [
|
||||
"X",
|
||||
{
|
||||
field: "var",
|
||||
column: varIndex,
|
||||
value: geneName,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
createColorByQuery(colors) {
|
||||
const { annoMatrix } = this.props;
|
||||
const { schema } = annoMatrix;
|
||||
const { colorMode, colorAccessor } = colors;
|
||||
return createColorQuery(colorMode, colorAccessor, schema);
|
||||
}
|
||||
|
||||
updateColorTable(colors, colorDf) {
|
||||
/* update color table state */
|
||||
const { annoMatrix } = this.props;
|
||||
const { schema } = annoMatrix;
|
||||
const { colorAccessor, userColors, colorMode } = colors;
|
||||
return createColorTable(
|
||||
colorMode,
|
||||
colorAccessor,
|
||||
colorDf,
|
||||
schema,
|
||||
userColors
|
||||
);
|
||||
}
|
||||
|
||||
async fetchData(
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor,
|
||||
colors,
|
||||
pointDilation
|
||||
) {
|
||||
const { annoMatrix } = this.props;
|
||||
const { metadataField: pointDilationAccessor } = pointDilation;
|
||||
|
||||
const promises = [];
|
||||
// X and Y dimensions
|
||||
promises.push(
|
||||
annoMatrix.fetch(...this.createXQuery(scatterplotXXaccessor))
|
||||
);
|
||||
promises.push(
|
||||
annoMatrix.fetch(...this.createXQuery(scatterplotYYaccessor))
|
||||
);
|
||||
|
||||
// color
|
||||
const query = this.createColorByQuery(colors);
|
||||
if (query) {
|
||||
promises.push(annoMatrix.fetch(...query));
|
||||
} else {
|
||||
promises.push(Promise.resolve(null));
|
||||
}
|
||||
|
||||
// point highlighting
|
||||
if (pointDilationAccessor) {
|
||||
promises.push(annoMatrix.fetch("obs", pointDilationAccessor));
|
||||
} else {
|
||||
promises.push(Promise.resolve(null));
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
renderCanvas = renderThrottle(() => {
|
||||
const {
|
||||
regl,
|
||||
drawPoints,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
flagBuffer,
|
||||
projectionTF,
|
||||
} = this.state;
|
||||
this.renderPoints(
|
||||
regl,
|
||||
drawPoints,
|
||||
flagBuffer,
|
||||
colorBuffer,
|
||||
pointBuffer,
|
||||
projectionTF
|
||||
);
|
||||
});
|
||||
|
||||
updateReglAndRender(newRenderCache) {
|
||||
const { positions, colors, flags } = newRenderCache;
|
||||
this.renderCache = newRenderCache;
|
||||
const { pointBuffer, colorBuffer, flagBuffer } = this.state;
|
||||
pointBuffer({ data: positions, dimension: 2 });
|
||||
colorBuffer({ data: colors, dimension: 3 });
|
||||
flagBuffer({ data: flags, dimension: 1 });
|
||||
this.renderCanvas();
|
||||
}
|
||||
|
||||
renderPoints(
|
||||
@@ -372,8 +386,10 @@ class Scatterplot extends React.PureComponent {
|
||||
pointBuffer,
|
||||
projectionTF
|
||||
) {
|
||||
if (!this.reglCanvas) return;
|
||||
const { universe } = this.props;
|
||||
const { annoMatrix } = this.props;
|
||||
if (!this.reglCanvas || !annoMatrix) return;
|
||||
|
||||
const { schema } = annoMatrix;
|
||||
const { viewport } = this.state;
|
||||
regl.poll();
|
||||
regl.clear({
|
||||
@@ -385,8 +401,8 @@ class Scatterplot extends React.PureComponent {
|
||||
color: colorBuffer,
|
||||
position: pointBuffer,
|
||||
projection: projectionTF,
|
||||
count: this.count,
|
||||
nPoints: universe.nObs,
|
||||
count: annoMatrix.nObs,
|
||||
nPoints: schema.dataframe.nObs,
|
||||
minViewportDimension: Math.min(
|
||||
viewport.width - globals.leftSidebarWidth || width,
|
||||
viewport.height || height
|
||||
@@ -396,8 +412,21 @@ class Scatterplot extends React.PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dispatch } = this.props;
|
||||
const { minimized } = this.state;
|
||||
const {
|
||||
dispatch,
|
||||
annoMatrix,
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor,
|
||||
colors,
|
||||
crossfilter,
|
||||
pointDilation,
|
||||
} = this.props;
|
||||
const { minimized, status, regl, viewport } = this.state;
|
||||
|
||||
if (status === "error") return null;
|
||||
if (regl) {
|
||||
this.renderCanvas();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -459,10 +488,42 @@ class Scatterplot extends React.PureComponent {
|
||||
marginLeft: margin.left,
|
||||
marginTop: margin.top,
|
||||
}}
|
||||
ref={(canvas) => {
|
||||
this.reglCanvas = canvas;
|
||||
}}
|
||||
ref={this.setReglCanvas}
|
||||
/>
|
||||
<Async
|
||||
watchFn={Scatterplot.watchAsync}
|
||||
promiseFn={this.fetchAsyncProps}
|
||||
watchProps={{
|
||||
annoMatrix,
|
||||
scatterplotXXaccessor,
|
||||
scatterplotYYaccessor,
|
||||
colors,
|
||||
crossfilter,
|
||||
pointDilation,
|
||||
viewport,
|
||||
}}
|
||||
>
|
||||
<Async.Pending initial>Loading...</Async.Pending>
|
||||
<Async.Rejected>{(error) => error.message}</Async.Rejected>
|
||||
<Async.Fulfilled>
|
||||
{(asyncProps) => {
|
||||
if (regl && !shallowEqual(asyncProps, this.renderCache)) {
|
||||
this.updateReglAndRender(asyncProps);
|
||||
}
|
||||
return (
|
||||
<ScatterplotAxis
|
||||
width={width}
|
||||
height={height}
|
||||
margin={margin}
|
||||
scatterplotYYaccessor={scatterplotXXaccessor}
|
||||
scatterplotXXaccessor={scatterplotYYaccessor}
|
||||
xScale={asyncProps.xScale}
|
||||
yScale={asyncProps.yScale}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Async.Fulfilled>
|
||||
</Async>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -470,3 +531,75 @@ class Scatterplot extends React.PureComponent {
|
||||
}
|
||||
|
||||
export default Scatterplot;
|
||||
|
||||
const ScatterplotAxis = React.memo(
|
||||
({ scatterplotYYaccessor, scatterplotXXaccessor, xScale, yScale }) => {
|
||||
/*
|
||||
Axis for the scatterplot, rendered with SVG/D3. Props:
|
||||
* scatterplotXXaccessor - name of X axis
|
||||
* scatterplotXXaccessor - name of Y axis
|
||||
* xScale - D3 scale for X axis (domain to range)
|
||||
* yScale - D3 scale for Y axis (domain to range)
|
||||
|
||||
This also relies on the GLOBAL width/height/margin constants. If those become
|
||||
become variables, may need to add the params.
|
||||
*/
|
||||
|
||||
const svgRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current) return;
|
||||
const svg = d3.select(svgRef.current);
|
||||
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
// the axes are much cleaner and easier now. No need to rotate and orient
|
||||
// the axis, just call axisBottom, axisLeft etc.
|
||||
const xAxis = d3.axisBottom().ticks(7).scale(xScale);
|
||||
const yAxis = d3.axisLeft().ticks(7).scale(yScale);
|
||||
|
||||
// adding axes is also simpler now, just translate x-axis to (0,height)
|
||||
// and it's alread defined to be a bottom axis.
|
||||
svg
|
||||
.append("g")
|
||||
.attr("transform", `translate(0,${height})`)
|
||||
.attr("class", "x axis")
|
||||
.call(xAxis);
|
||||
|
||||
// y-axis is translated to (0,0)
|
||||
svg
|
||||
.append("g")
|
||||
.attr("transform", "translate(0,0)")
|
||||
.attr("class", "y axis")
|
||||
.call(yAxis);
|
||||
|
||||
// adding label. For x-axis, it's at (10, 10), and for y-axis at (width, height-10).
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", 10)
|
||||
.attr("y", 10)
|
||||
.attr("class", "label")
|
||||
.style("font-style", "italic")
|
||||
.text(scatterplotYYaccessor);
|
||||
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width)
|
||||
.attr("y", height - 10)
|
||||
.attr("text-anchor", "end")
|
||||
.attr("class", "label")
|
||||
.style("font-style", "italic")
|
||||
.text(scatterplotXXaccessor);
|
||||
}, [scatterplotXXaccessor, scatterplotYYaccessor, xScale, yScale]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width + margin.left + margin.right}
|
||||
height={height + margin.top + margin.bottom}
|
||||
data-testid="scatterplot-svg"
|
||||
>
|
||||
<g ref={svgRef} transform={`translate(${margin.left},${margin.top})`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// jshint esversion: 6
|
||||
/*****************************************
|
||||
******************************************
|
||||
Setup SVG & Canvas elements
|
||||
******************************************
|
||||
******************************************/
|
||||
|
||||
import * as d3 from "d3";
|
||||
|
||||
const setupScatterplot = (width, height, margin) => {
|
||||
const container = d3.select("#scatterplot");
|
||||
|
||||
const svg = container
|
||||
.append("svg")
|
||||
.attr("width", width + margin.left + margin.right)
|
||||
.attr("height", height + margin.top + margin.bottom)
|
||||
.attr("data-testid", "scatterplot-svg")
|
||||
.append("g")
|
||||
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
return {
|
||||
svg,
|
||||
};
|
||||
};
|
||||
|
||||
export default setupScatterplot;
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Reducer for the annoMatrix
|
||||
*/
|
||||
|
||||
const AnnoMatrix = (state = null, action) => {
|
||||
if (action.annoMatrix) {
|
||||
return action.annoMatrix;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export default AnnoMatrix;
|
||||
@@ -2,22 +2,17 @@ const Autosave = (
|
||||
state = {
|
||||
saveInProgress: false,
|
||||
error: false,
|
||||
lastSavedObsAnnotations: null,
|
||||
initialDataLoadComplete: false,
|
||||
lastSavedAnnoMatrix: null,
|
||||
},
|
||||
action,
|
||||
nextSharedState
|
||||
action
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)": {
|
||||
/* don't save on init */
|
||||
const { universe } = nextSharedState;
|
||||
case "annoMatrix: init complete": {
|
||||
return {
|
||||
...state,
|
||||
error: false,
|
||||
saveInProgress: false,
|
||||
lastSavedObsAnnotations: universe.obsAnnotations,
|
||||
initialDataLoadComplete: true,
|
||||
lastSavedAnnoMatrix: action.annoMatrix,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,21 +24,20 @@ const Autosave = (
|
||||
}
|
||||
|
||||
case "writable obs annotations - save error": {
|
||||
const { message } = action;
|
||||
return {
|
||||
...state,
|
||||
error: message,
|
||||
error: action.message,
|
||||
saveInProgress: false,
|
||||
};
|
||||
}
|
||||
|
||||
case "writable obs annotations - save complete": {
|
||||
const lastSavedObsAnnotations = action.obsAnnotations;
|
||||
const { lastSavedAnnoMatrix } = action;
|
||||
return {
|
||||
...state,
|
||||
saveInProgress: false,
|
||||
error: false,
|
||||
lastSavedObsAnnotations,
|
||||
lastSavedAnnoMatrix,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,92 +13,28 @@ Label state default (if missing) is up to the component, but typically true.
|
||||
*/
|
||||
const CategoricalSelection = (state, action, nextSharedState) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)":
|
||||
case "set World to current selection":
|
||||
case "reset World to eq Universe":
|
||||
case "initial data load complete":
|
||||
case "subset to selection":
|
||||
case "reset subset":
|
||||
case "set clip quantiles": {
|
||||
const { world } = nextSharedState;
|
||||
const { annoMatrix } = nextSharedState;
|
||||
const newState = CH.createCategoricalSelection(
|
||||
CH.selectableCategoryNames(world.schema)
|
||||
CH.selectableCategoryNames(annoMatrix.schema)
|
||||
);
|
||||
return newState;
|
||||
}
|
||||
|
||||
case "universe: column load success": {
|
||||
const { dim } = action;
|
||||
if (dim !== "obsAnnotations") return state;
|
||||
|
||||
const { dataframe } = action;
|
||||
const { world } = nextSharedState;
|
||||
const names = CH.selectableCategoryNames(
|
||||
world.schema,
|
||||
dataframe.colIndex.labels()
|
||||
);
|
||||
if (names.length === 0) return state;
|
||||
case "categorical metadata filter select":
|
||||
case "categorical metadata filter deselect":
|
||||
case "categorical metadata filter none of these":
|
||||
case "categorical metadata filter all of these": {
|
||||
const { metadataField, labelSelectionState } = action;
|
||||
return {
|
||||
...state,
|
||||
...CH.createCategoricalSelection(names),
|
||||
[metadataField]: labelSelectionState,
|
||||
};
|
||||
}
|
||||
|
||||
case "categorical metadata filter select": {
|
||||
/*
|
||||
Set the specific category in this field to false
|
||||
*/
|
||||
const { metadataField, label } = action;
|
||||
const newSelected = new Map(state[metadataField]);
|
||||
newSelected.set(label, true);
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: newSelected,
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
case "categorical metadata filter deselect": {
|
||||
/*
|
||||
Set the specific category in this field to false
|
||||
*/
|
||||
const { metadataField, label } = action;
|
||||
const newSelected = new Map(state[metadataField]);
|
||||
newSelected.set(label, false);
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: newSelected,
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
case "categorical metadata filter none of these": {
|
||||
/*
|
||||
set all categories in this field to false.
|
||||
*/
|
||||
const { metadataField, labels } = action;
|
||||
const { selected } = state[metadataField];
|
||||
const newSelected = new Map(selected);
|
||||
labels.forEach((label) => newSelected.set(label, false));
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: newSelected,
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
case "categorical metadata filter all of these": {
|
||||
/*
|
||||
set all categories in this field to true.
|
||||
*/
|
||||
const { metadataField, labels } = action;
|
||||
const { selected } = state[metadataField];
|
||||
const newSelected = new Map(selected);
|
||||
labels.forEach((label) => newSelected.set(label, true));
|
||||
const newCategoricalSelection = {
|
||||
...state,
|
||||
[action.metadataField]: newSelected,
|
||||
};
|
||||
return newCategoricalSelection;
|
||||
}
|
||||
|
||||
case "annotation: create category": {
|
||||
const name = action.data;
|
||||
return {
|
||||
|
||||
@@ -1,61 +1,24 @@
|
||||
import calcCentroid from "../util/centroid";
|
||||
|
||||
const initialState = {
|
||||
labels: [],
|
||||
showLabels: false,
|
||||
};
|
||||
|
||||
const centroidLabels = (state = initialState, action, sharedNextState) => {
|
||||
const {
|
||||
world,
|
||||
layoutChoice,
|
||||
categoricalSelection,
|
||||
colors: { colorAccessor },
|
||||
} = sharedNextState;
|
||||
|
||||
const showLabels = action.showLabels ?? state.showLabels;
|
||||
|
||||
switch (action.type) {
|
||||
case "annotation: label current cell selection":
|
||||
case "annotation: label edited":
|
||||
case "annotation: delete label":
|
||||
case "set layout choice":
|
||||
case "set World to current selection":
|
||||
case "reset World to eq Universe":
|
||||
return {
|
||||
...state,
|
||||
labels:
|
||||
!!colorAccessor && showLabels && !!categoricalSelection[colorAccessor]
|
||||
? calcCentroid(world, colorAccessor, layoutChoice.currentDimNames)
|
||||
: [],
|
||||
};
|
||||
|
||||
case "color by categorical metadata":
|
||||
case "show centroid labels for category":
|
||||
// If colorby is not enabled or labels are not toggled to show
|
||||
// then clear the labels and make sure the toggle is off
|
||||
if (!colorAccessor || !showLabels) {
|
||||
return {
|
||||
...state,
|
||||
labels: [],
|
||||
showLabels,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
labels: calcCentroid(
|
||||
world,
|
||||
colorAccessor,
|
||||
layoutChoice.currentDimNames
|
||||
),
|
||||
showLabels,
|
||||
showLabels: colorAccessor && showLabels,
|
||||
};
|
||||
|
||||
case "color by continuous metadata":
|
||||
case "color by expression":
|
||||
return { ...state, labels: [] };
|
||||
|
||||
case "reset centroid labels":
|
||||
return initialState;
|
||||
|
||||
|
||||
+16
-111
@@ -1,32 +1,17 @@
|
||||
import { ColorHelpers } from "../util/stateManager";
|
||||
/*
|
||||
Color By UI state
|
||||
*/
|
||||
|
||||
const ColorsReducer = (
|
||||
state = {
|
||||
colorMode: null,
|
||||
colorAccessor: null,
|
||||
rgb: null,
|
||||
scale: null,
|
||||
},
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "universe exists, but loading is still in progress": {
|
||||
/* initialize everything with default colors, no mode, no color-by accessor */
|
||||
const { world } = nextSharedState;
|
||||
const colorMode = null;
|
||||
const colorAccessor = null;
|
||||
const { rgb, scale } = ColorHelpers.createColors(world);
|
||||
return {
|
||||
...state,
|
||||
colorAccessor,
|
||||
colorMode,
|
||||
rgb,
|
||||
scale,
|
||||
};
|
||||
}
|
||||
|
||||
case "universe: user color load success": {
|
||||
const { userColors } = action;
|
||||
return {
|
||||
@@ -35,46 +20,17 @@ const ColorsReducer = (
|
||||
};
|
||||
}
|
||||
|
||||
case "reset World to eq Universe": {
|
||||
/* need to rebuild colors as world may have changed, but don't switch modes */
|
||||
const { world } = nextSharedState;
|
||||
const { colorMode, colorAccessor } = state;
|
||||
const { rgb, scale } = ColorHelpers.createColors(
|
||||
world,
|
||||
colorMode,
|
||||
colorAccessor
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
rgb,
|
||||
scale,
|
||||
};
|
||||
}
|
||||
|
||||
case "clear differential expression":
|
||||
case "set clip quantiles":
|
||||
case "set World to current selection": {
|
||||
const { world: prevWorld, controls: prevControls } = prevSharedState;
|
||||
const resetColorState = ColorHelpers.checkIfColorByDiffexpAndResetColors(
|
||||
prevControls,
|
||||
state,
|
||||
prevWorld
|
||||
);
|
||||
if (resetColorState) {
|
||||
return resetColorState;
|
||||
case "subset to selection": {
|
||||
const { controls: prevControls } = prevSharedState;
|
||||
if (prevControls.diffexpGenes.includes(state.colorAccessor)) {
|
||||
return {
|
||||
colorMode: null,
|
||||
colorAccessor: null,
|
||||
};
|
||||
}
|
||||
|
||||
const { colorMode, colorAccessor } = state;
|
||||
const { world } = nextSharedState;
|
||||
const { rgb, scale } = ColorHelpers.createColors(
|
||||
world,
|
||||
colorMode,
|
||||
colorAccessor
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
rgb,
|
||||
scale,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
case "annotation: delete category": {
|
||||
@@ -85,21 +41,21 @@ const ColorsReducer = (
|
||||
/* else reset */
|
||||
return {
|
||||
...state,
|
||||
...ColorHelpers.resetColors(prevSharedState.world),
|
||||
colorMode: null,
|
||||
colorAccessor: null,
|
||||
};
|
||||
}
|
||||
|
||||
case "reset colorscale": {
|
||||
return {
|
||||
...state,
|
||||
...ColorHelpers.resetColors(prevSharedState.world),
|
||||
colorMode: null,
|
||||
colorAccessor: null,
|
||||
};
|
||||
}
|
||||
|
||||
case "color by categorical metadata":
|
||||
case "color by continuous metadata": {
|
||||
const { world, colors } = prevSharedState;
|
||||
|
||||
/* toggle between this mode and reset */
|
||||
const resetCurrent =
|
||||
action.type === state.colorMode &&
|
||||
@@ -107,78 +63,27 @@ const ColorsReducer = (
|
||||
const colorMode = !resetCurrent ? action.type : null;
|
||||
const colorAccessor = !resetCurrent ? action.colorAccessor : null;
|
||||
|
||||
const { rgb, scale } = ColorHelpers.createColors(
|
||||
world,
|
||||
colorMode,
|
||||
colorAccessor,
|
||||
colors.userColors
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
colorMode,
|
||||
colorAccessor,
|
||||
rgb,
|
||||
scale,
|
||||
};
|
||||
}
|
||||
|
||||
case "color by expression": {
|
||||
const { world } = prevSharedState;
|
||||
|
||||
/* toggle between this mode and reset */
|
||||
const resetCurrent =
|
||||
action.type === state.colorMode && action.gene === state.colorAccessor;
|
||||
const colorMode = !resetCurrent ? action.type : null;
|
||||
const colorAccessor = !resetCurrent ? action.gene : null;
|
||||
|
||||
const { rgb, scale } = ColorHelpers.createColors(
|
||||
world,
|
||||
colorMode,
|
||||
colorAccessor
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
colorMode,
|
||||
colorAccessor,
|
||||
rgb,
|
||||
scale,
|
||||
};
|
||||
}
|
||||
|
||||
case "annotation: add new label to category":
|
||||
case "annotation: label current cell selection":
|
||||
case "annotation: delete label": {
|
||||
const { world } = nextSharedState;
|
||||
const { colorMode, colorAccessor } = state;
|
||||
const { metadataField } = action;
|
||||
if (
|
||||
colorMode !== "color by categorical metadata" ||
|
||||
colorAccessor !== metadataField
|
||||
)
|
||||
return state;
|
||||
|
||||
/* else, we need to rebuild colors as labels have changed! */
|
||||
const { rgb, scale } = ColorHelpers.createColors(
|
||||
world,
|
||||
colorMode,
|
||||
colorAccessor
|
||||
);
|
||||
return { ...state, rgb, scale };
|
||||
}
|
||||
|
||||
case "clear differential expression": {
|
||||
const { world: prevWorld, controls: prevControls } = prevSharedState;
|
||||
const resetColorState = ColorHelpers.checkIfColorByDiffexpAndResetColors(
|
||||
prevControls,
|
||||
state,
|
||||
prevWorld
|
||||
);
|
||||
if (resetColorState) {
|
||||
return resetColorState;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { makeContinuousDimensionName } from "../util/nameCreators";
|
||||
|
||||
const ContinuousSelection = (state = {}, action) => {
|
||||
switch (action.type) {
|
||||
case "reset World to eq Universe":
|
||||
case "reset subset":
|
||||
case "set clip quantiles": {
|
||||
return {};
|
||||
}
|
||||
|
||||
Vendored
+6
-52
@@ -21,9 +21,7 @@ const Controls = (
|
||||
scatterplotYYaccessor: null,
|
||||
graphRenderCounter: 0 /* integer as <Component key={graphRenderCounter} - a change in key forces a remount */,
|
||||
},
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
action
|
||||
) => {
|
||||
/*
|
||||
For now, log anything looking like an error to the console.
|
||||
@@ -33,37 +31,10 @@ const Controls = (
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
/*****************************************************
|
||||
Initialization, World/Universe management
|
||||
and data loading.
|
||||
******************************************************/
|
||||
case "initial data load start": {
|
||||
return { ...state, loading: true };
|
||||
}
|
||||
case "universe: column load success": {
|
||||
/*
|
||||
we are in a loading state until the following are true:
|
||||
* universe exists (if partially)
|
||||
* embeddings (obsLayout) are loaded
|
||||
* varAnnotations index is loaded
|
||||
*/
|
||||
const universeExists = !!nextSharedState.universe;
|
||||
const embeddingsExist =
|
||||
!nextSharedState.universe?.obsLayout?.isEmpty() ?? false;
|
||||
const varIndex = nextSharedState.universe.schema.annotations.var.index;
|
||||
const varAnnotationsIndexExists =
|
||||
universeExists &&
|
||||
nextSharedState.universe.varAnnotations.hasCol(varIndex);
|
||||
return {
|
||||
...state,
|
||||
loading: !(
|
||||
universeExists &&
|
||||
embeddingsExist &&
|
||||
varAnnotationsIndexExists
|
||||
),
|
||||
};
|
||||
}
|
||||
case "initial data load complete (universe exists)": {
|
||||
case "initial data load complete": {
|
||||
/* now fully loaded */
|
||||
return {
|
||||
...state,
|
||||
@@ -72,7 +43,7 @@ const Controls = (
|
||||
resettingInterface: false,
|
||||
};
|
||||
}
|
||||
case "reset World to eq Universe": {
|
||||
case "reset subset": {
|
||||
const [newUserDefinedGenes, newDiffExpGenes] = subsetAndResetGeneLists(
|
||||
state
|
||||
);
|
||||
@@ -83,7 +54,7 @@ const Controls = (
|
||||
diffexpGenes: newDiffExpGenes,
|
||||
};
|
||||
}
|
||||
case "set World to current selection": {
|
||||
case "subset to selection": {
|
||||
const [newUserDefinedGenes, newDiffExpGenes] = subsetAndResetGeneLists(
|
||||
state
|
||||
);
|
||||
@@ -119,15 +90,10 @@ const Controls = (
|
||||
};
|
||||
}
|
||||
case "request differential expression success": {
|
||||
const { world } = prevSharedState;
|
||||
const varIndexName = world.schema.annotations.var.index;
|
||||
const _diffexpGenes = [];
|
||||
action.data.forEach((d) => {
|
||||
_diffexpGenes.push(world.varAnnotations.at(d[0], varIndexName));
|
||||
});
|
||||
const diffexpGenes = action.data.map((v) => v[0]);
|
||||
return {
|
||||
...state,
|
||||
diffexpGenes: _diffexpGenes,
|
||||
diffexpGenes,
|
||||
};
|
||||
}
|
||||
case "clear differential expression": {
|
||||
@@ -147,12 +113,6 @@ const Controls = (
|
||||
userDefinedGenes: newUserDefinedGenes,
|
||||
};
|
||||
}
|
||||
case "clear all user defined genes": {
|
||||
return {
|
||||
...state,
|
||||
userDefinedGenes: [],
|
||||
};
|
||||
}
|
||||
case "initial data load error": {
|
||||
return {
|
||||
...state,
|
||||
@@ -181,12 +141,6 @@ const Controls = (
|
||||
graphRenderCounter: c,
|
||||
};
|
||||
}
|
||||
case "interface reset started": {
|
||||
return {
|
||||
...state,
|
||||
resettingInterface: true,
|
||||
};
|
||||
}
|
||||
|
||||
/*******************************
|
||||
Scatterplot
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
import _ from "lodash";
|
||||
|
||||
import Crossfilter from "../util/typedCrossfilter";
|
||||
import {
|
||||
World,
|
||||
ControlsHelpers,
|
||||
AnnotationsHelpers,
|
||||
} from "../util/stateManager";
|
||||
import {
|
||||
layoutDimensionName,
|
||||
obsAnnoDimensionName,
|
||||
userDefinedDimensionName,
|
||||
diffexpDimensionName,
|
||||
makeContinuousDimensionName,
|
||||
} from "../util/nameCreators";
|
||||
|
||||
const XYDimName = layoutDimensionName("XY");
|
||||
|
||||
const CrossfilterReducerBase = (
|
||||
state = null,
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "universe: column load success": {
|
||||
const { world, layoutChoice } = nextSharedState;
|
||||
const { obsAnnotations, obsLayout } = world;
|
||||
|
||||
// ignore var dimension loads as these are not currently selectable
|
||||
if (action.dim === "varAnnotations") return state;
|
||||
|
||||
/*
|
||||
during bootstrap loading, we don't know if obsLayout or obsAnnotations
|
||||
will load first. Take whichever arrives and is not empty (so that our
|
||||
crossfilter has the right dimensionality).
|
||||
*/
|
||||
let crossfilter =
|
||||
state ??
|
||||
new Crossfilter(obsAnnotations.isEmpty() ? obsLayout : obsAnnotations);
|
||||
|
||||
// add layout dimension, if not already present
|
||||
if (
|
||||
obsLayout.hasCol(layoutChoice.currentDimNames[0]) &&
|
||||
!crossfilter.hasDimension(XYDimName)
|
||||
) {
|
||||
crossfilter = crossfilter.addDimension(
|
||||
XYDimName,
|
||||
"spatial",
|
||||
obsLayout.col(layoutChoice.currentDimNames[0]).asArray(),
|
||||
obsLayout.col(layoutChoice.currentDimNames[1]).asArray()
|
||||
);
|
||||
}
|
||||
|
||||
// add any missing obsAnnotations
|
||||
crossfilter = World.addObsDimensions(crossfilter, world);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "reset World to eq Universe": {
|
||||
const { userDefinedGenes, diffexpGenes } = nextSharedState.controls;
|
||||
const { world } = nextSharedState;
|
||||
let { crossfilter } = prevSharedState.resetCache;
|
||||
crossfilter = ControlsHelpers.createGeneDimensions(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
world,
|
||||
crossfilter
|
||||
);
|
||||
crossfilter = AnnotationsHelpers.createWritableAnnotationDimensions(
|
||||
world,
|
||||
crossfilter
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "set clip quantiles":
|
||||
case "set World to current selection": {
|
||||
const { userDefinedGenes, diffexpGenes } = nextSharedState.controls;
|
||||
const { world, layoutChoice } = nextSharedState;
|
||||
let crossfilter = new Crossfilter(world.obsAnnotations);
|
||||
crossfilter = World.createObsDimensions(
|
||||
crossfilter,
|
||||
world,
|
||||
layoutChoice.currentDimNames
|
||||
);
|
||||
crossfilter = ControlsHelpers.createGeneDimensions(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
world,
|
||||
crossfilter
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "set layout choice": {
|
||||
/*
|
||||
when switching layouts:
|
||||
- delete the existing XY index
|
||||
- add the new XY index (which implicitly selects all on it)
|
||||
*/
|
||||
const { world, layoutChoice } = nextSharedState;
|
||||
return state
|
||||
.delDimension(layoutDimensionName("XY"))
|
||||
.addDimension(
|
||||
layoutDimensionName("XY"),
|
||||
"spatial",
|
||||
world.obsLayout.col(layoutChoice.currentDimNames[0]).asArray(),
|
||||
world.obsLayout.col(layoutChoice.currentDimNames[1]).asArray()
|
||||
);
|
||||
}
|
||||
|
||||
case "request user defined gene success": {
|
||||
const { world } = nextSharedState;
|
||||
const gene = action.data.genes[0];
|
||||
return state.addDimension(
|
||||
userDefinedDimensionName(gene),
|
||||
"scalar",
|
||||
world.varData.col(gene).asArray(),
|
||||
Float32Array
|
||||
);
|
||||
}
|
||||
|
||||
case "request differential expression success": {
|
||||
const { world } = nextSharedState;
|
||||
const varIndexName = world.schema.annotations.var.index;
|
||||
const genes = _.map(action.data, (d) =>
|
||||
world.varAnnotations.at(d[0], varIndexName)
|
||||
);
|
||||
const crossfilter = _.reduce(
|
||||
genes,
|
||||
(xfltr, gene) =>
|
||||
xfltr.addDimension(
|
||||
diffexpDimensionName(gene),
|
||||
"scalar",
|
||||
world.varData.col(gene).asArray(),
|
||||
Float32Array
|
||||
),
|
||||
state
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "clear differential expression": {
|
||||
const { world } = nextSharedState;
|
||||
const varIndexName = world.schema.annotations.var.index;
|
||||
const crossfilter = _.reduce(
|
||||
action.diffExp,
|
||||
(xfltr, values) => {
|
||||
const name = world.varAnnotations.at(values[0], varIndexName);
|
||||
return xfltr.delDimension(diffexpDimensionName(name));
|
||||
},
|
||||
state
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "clear user defined gene": {
|
||||
return state.delDimension(userDefinedDimensionName(action.data));
|
||||
}
|
||||
|
||||
case "clear all user defined genes": {
|
||||
const { userDefinedGenes } = nextSharedState.controls;
|
||||
const crossfilter = _.reduce(
|
||||
userDefinedGenes,
|
||||
(xfltr, gene) => xfltr.delDimension(userDefinedDimensionName(gene)),
|
||||
state
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "annotation: create category": {
|
||||
const name = action.data;
|
||||
const { world } = nextSharedState;
|
||||
const colData = world.obsAnnotations.col(name).asArray();
|
||||
return state.addDimension(obsAnnoDimensionName(name), "enum", colData);
|
||||
}
|
||||
|
||||
case "annotation: category edited": {
|
||||
const name = action.metadataField;
|
||||
const newName = action.newCategoryText;
|
||||
return state.renameDimension(
|
||||
obsAnnoDimensionName(name),
|
||||
obsAnnoDimensionName(newName)
|
||||
);
|
||||
}
|
||||
|
||||
case "annotation: delete category": {
|
||||
return state.delDimension(obsAnnoDimensionName(action.metadataField));
|
||||
}
|
||||
|
||||
case "annotation: add new label to category":
|
||||
case "annotation: label current cell selection":
|
||||
case "annotation: label edited":
|
||||
case "annotation: delete label": {
|
||||
if (
|
||||
action.type === "annotation: add new label to category" &&
|
||||
!action.assignSelectedCells
|
||||
)
|
||||
return state;
|
||||
|
||||
/* we need to reindex the dimension. For now, just drop it and add another */
|
||||
const name = action.metadataField;
|
||||
const dimName = obsAnnoDimensionName(name);
|
||||
const { world } = nextSharedState;
|
||||
const colData = world.obsAnnotations.col(name).asArray();
|
||||
return state.delDimension(dimName).addDimension(dimName, "enum", colData);
|
||||
}
|
||||
|
||||
case "graph brush end":
|
||||
case "graph brush change": {
|
||||
const [minX, maxY] = action.brushCoords.northwest;
|
||||
const [maxX, minY] = action.brushCoords.southeast;
|
||||
return state.select(XYDimName, {
|
||||
mode: "within-rect",
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
});
|
||||
}
|
||||
|
||||
case "graph lasso end": {
|
||||
const { polygon } = action;
|
||||
return state.select(XYDimName, {
|
||||
mode: "within-polygon",
|
||||
polygon,
|
||||
});
|
||||
}
|
||||
|
||||
case "graph lasso cancel":
|
||||
case "graph brush cancel":
|
||||
case "graph lasso deselect":
|
||||
case "graph brush deselect": {
|
||||
return state.select(XYDimName, { mode: "all" });
|
||||
}
|
||||
|
||||
case "continuous metadata histogram start":
|
||||
case "continuous metadata histogram brush":
|
||||
case "continuous metadata histogram cancel":
|
||||
case "continuous metadata histogram end": {
|
||||
const name = makeContinuousDimensionName(
|
||||
action.continuousNamespace,
|
||||
action.selection
|
||||
);
|
||||
// action.selection: metadata name being selected
|
||||
// action.range: filter range, or null if deselected
|
||||
if (!action.range) {
|
||||
return state.select(name, { mode: "all" });
|
||||
}
|
||||
const [lo, hi] = action.range;
|
||||
const newState = state.select(name, {
|
||||
mode: "range",
|
||||
lo,
|
||||
hi,
|
||||
inclusive: true, // [lo, hi] incluisve selection
|
||||
});
|
||||
return newState;
|
||||
}
|
||||
|
||||
case "categorical metadata filter select":
|
||||
case "categorical metadata filter deselect": {
|
||||
const { labels, metadataField } = action;
|
||||
const selected = nextSharedState.categoricalSelection[metadataField];
|
||||
const values = labels.filter((label) => selected.get(label) ?? true);
|
||||
return state.select(obsAnnoDimensionName(action.metadataField), {
|
||||
mode: "exact",
|
||||
values,
|
||||
});
|
||||
}
|
||||
|
||||
case "categorical metadata filter none of these": {
|
||||
return state.select(obsAnnoDimensionName(action.metadataField), {
|
||||
mode: "none",
|
||||
});
|
||||
}
|
||||
|
||||
case "categorical metadata filter all of these": {
|
||||
return state.select(obsAnnoDimensionName(action.metadataField), {
|
||||
mode: "all",
|
||||
});
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
IMPORTANT: the system assumes that crossfilter.data() will point at the
|
||||
same value as world.obsAnnotations. For actions handled in this reducer,
|
||||
make sure that this remains true.
|
||||
|
||||
This wrapper performs only this function.
|
||||
*/
|
||||
const CrossfilterReducer = (
|
||||
state,
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
const nextState = CrossfilterReducerBase(
|
||||
state,
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
);
|
||||
/*
|
||||
update the data in the crossfilter to point at the current obsAnnotations, IF
|
||||
they are not empty. If empty, leave it alone (can occur during boostrap loading).
|
||||
*/
|
||||
const nextObsAnnotations = nextSharedState.world?.obsAnnotations;
|
||||
if (
|
||||
!nextState ||
|
||||
nextState.all() === nextObsAnnotations ||
|
||||
nextObsAnnotations.isEmpty()
|
||||
) {
|
||||
return nextState;
|
||||
}
|
||||
return nextState.setData(nextObsAnnotations);
|
||||
};
|
||||
|
||||
export default CrossfilterReducer;
|
||||
@@ -47,8 +47,8 @@ const Differential = (
|
||||
celllist1: null,
|
||||
celllist2: null,
|
||||
};
|
||||
case "reset World to eq Universe":
|
||||
case "set World to current selection":
|
||||
case "reset subset":
|
||||
case "subset to selection":
|
||||
return {
|
||||
...state,
|
||||
diffExp: null,
|
||||
|
||||
@@ -7,7 +7,7 @@ const GraphSelection = (
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "set clip quantiles":
|
||||
case "reset World to eq Universe":
|
||||
case "reset subset":
|
||||
case "set layout choice": {
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -4,31 +4,30 @@ import thunk from "redux-thunk";
|
||||
import cascadeReducers from "./cascade";
|
||||
import undoable from "./undoable";
|
||||
import config from "./config";
|
||||
import universe from "./universe";
|
||||
import world from "./world";
|
||||
import annoMatrix from "./annoMatrix";
|
||||
import obsCrossfilter from "./obsCrossfilter";
|
||||
import categoricalSelection from "./categoricalSelection";
|
||||
import continuousSelection from "./continuousSelection";
|
||||
import graphSelection from "./graphSelection";
|
||||
import crossfilter from "./crossfilter";
|
||||
import colors from "./colors";
|
||||
import differential from "./differential";
|
||||
import layoutChoice from "./layoutChoice";
|
||||
import controls from "./controls";
|
||||
import resetCache from "./resetCache";
|
||||
import annotations from "./annotations";
|
||||
import autosave from "./autosave";
|
||||
import ontology from "./ontology";
|
||||
import centroidLabels from "./centroidLabels";
|
||||
import pointDialation from "./pointDilation";
|
||||
import { reembedController, reembedding } from "./reembed";
|
||||
import { gcMiddleware as annoMatrixGC } from "../annoMatrix";
|
||||
|
||||
import undoableConfig from "./undoableConfig";
|
||||
|
||||
const Reducer = undoable(
|
||||
cascadeReducers([
|
||||
["config", config],
|
||||
["universe", universe],
|
||||
["world", world],
|
||||
["annoMatrix", annoMatrix],
|
||||
["obsCrossfilter", obsCrossfilter],
|
||||
["ontology", ontology],
|
||||
["annotations", annotations],
|
||||
["reembedding", reembedding],
|
||||
@@ -38,21 +37,18 @@ const Reducer = undoable(
|
||||
["graphSelection", graphSelection],
|
||||
["colors", colors],
|
||||
["controls", controls],
|
||||
["crossfilter", crossfilter],
|
||||
["differential", differential],
|
||||
["centroidLabels", centroidLabels],
|
||||
["pointDilation", pointDialation],
|
||||
["reembedController", reembedController],
|
||||
["autosave", autosave],
|
||||
["resetCache", resetCache],
|
||||
]),
|
||||
[
|
||||
"universe",
|
||||
"world",
|
||||
"annoMatrix",
|
||||
"obsCrossfilter",
|
||||
"categoricalSelection",
|
||||
"continuousSelection",
|
||||
"graphSelection",
|
||||
"crossfilter",
|
||||
"colors",
|
||||
"controls",
|
||||
"differential",
|
||||
@@ -64,6 +60,6 @@ const Reducer = undoable(
|
||||
undoableConfig
|
||||
);
|
||||
|
||||
const store = createStore(Reducer, applyMiddleware(thunk));
|
||||
const store = createStore(Reducer, applyMiddleware(thunk, annoMatrixGC));
|
||||
|
||||
export default store;
|
||||
|
||||
@@ -14,8 +14,7 @@ function bestDefaultLayout(layouts) {
|
||||
return layouts[0];
|
||||
}
|
||||
|
||||
function setToDefaultLayout(world) {
|
||||
const { schema } = world;
|
||||
function setToDefaultLayout(schema) {
|
||||
const available = schema.layout.obs.map((v) => v.name).sort();
|
||||
const current = bestDefaultLayout(available);
|
||||
const currentDimNames = schema.layout.obsByName[current].dims;
|
||||
@@ -32,17 +31,17 @@ const LayoutChoice = (
|
||||
nextSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "universe exists, but loading is still in progress": {
|
||||
case "initial data load complete": {
|
||||
// set default to default
|
||||
const { universe } = nextSharedState;
|
||||
const { annoMatrix } = nextSharedState;
|
||||
return {
|
||||
...state,
|
||||
...setToDefaultLayout(universe),
|
||||
...setToDefaultLayout(annoMatrix.schema),
|
||||
};
|
||||
}
|
||||
|
||||
case "set layout choice": {
|
||||
const { schema } = nextSharedState.world;
|
||||
const { schema } = nextSharedState.annoMatrix;
|
||||
const current = action.layoutChoice;
|
||||
const currentDimNames = schema.layout.obsByName[current].dims;
|
||||
return { ...state, current, currentDimNames };
|
||||
@@ -58,9 +57,9 @@ const LayoutChoice = (
|
||||
}
|
||||
|
||||
case "reembed: clear all reembeddings": {
|
||||
const { universe } = nextSharedState;
|
||||
const { annoMatrix } = nextSharedState;
|
||||
const { current } = state;
|
||||
const dflt = setToDefaultLayout(universe);
|
||||
const dflt = setToDefaultLayout(annoMatrix.schema);
|
||||
if (dflt.available.includes(current)) {
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Reducer for the obsCrossfilter
|
||||
*/
|
||||
|
||||
const ObsCrossfilter = (state = null, action) => {
|
||||
if (action.obsCrossfilter) {
|
||||
return action.obsCrossfilter;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export default ObsCrossfilter;
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
Reducer which caches derived state to be used in a reset or other
|
||||
recomputation. Add stuff here you want stashed at init time (or whenever),
|
||||
for later use.
|
||||
|
||||
Currently this only caches the baseline (full universe) crossfilter,
|
||||
which improves Reset UI performance.
|
||||
*/
|
||||
const ResetCacheReducer = (
|
||||
state = {
|
||||
crossfilter: null,
|
||||
},
|
||||
action,
|
||||
nextSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initial data load complete (universe exists)":
|
||||
case "set layout choice": {
|
||||
const { crossfilter } = nextSharedState;
|
||||
return {
|
||||
...state,
|
||||
crossfilter,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default ResetCacheReducer;
|
||||
@@ -8,12 +8,10 @@ const stateKey = "@@undoable/filterState";
|
||||
these actions will not affect history
|
||||
*/
|
||||
const skipOnActions = new Set([
|
||||
"annoMatrix: init complete",
|
||||
"url changed",
|
||||
"interface reset started",
|
||||
"initial data load start",
|
||||
"universe: column load success",
|
||||
"universe: user color load success",
|
||||
"universe exists, but loading is still in progress",
|
||||
"configuration load complete",
|
||||
"increment graph render counter",
|
||||
"window resize",
|
||||
@@ -23,16 +21,7 @@ const skipOnActions = new Set([
|
||||
"graph brush change",
|
||||
"continuous metadata histogram brush",
|
||||
|
||||
"expression load start",
|
||||
"expression load success",
|
||||
"expression load error",
|
||||
|
||||
"request user defined gene started",
|
||||
"request user defined gene success",
|
||||
"clear all user defined genes",
|
||||
|
||||
"get single gene expression for coloring started",
|
||||
"get single gene expression for coloring error",
|
||||
|
||||
"category value mouse hover start",
|
||||
"category value mouse hover end",
|
||||
@@ -62,8 +51,7 @@ const debounceOnActions = new Set([]);
|
||||
history will be cleared when these actions occur
|
||||
*/
|
||||
const clearOnActions = new Set([
|
||||
"initial data load complete (universe exists)",
|
||||
"reset World to eq Universe",
|
||||
"initial data load complete",
|
||||
"initial data load error",
|
||||
]);
|
||||
|
||||
@@ -88,7 +76,7 @@ const saveOnActions = new Set([
|
||||
"store current cell selection as differential set 1",
|
||||
"store current cell selection as differential set 2",
|
||||
|
||||
"set World to current selection",
|
||||
"subset to selection",
|
||||
"set clip quantiles",
|
||||
|
||||
"set layout choice",
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
import { unassignedCategoryLabel } from "../globals";
|
||||
import {
|
||||
addObsAnnotations,
|
||||
addVarAnnotations,
|
||||
addObsLayout,
|
||||
} from "../util/stateManager/universe";
|
||||
import {
|
||||
World,
|
||||
ControlsHelpers,
|
||||
AnnotationsHelpers,
|
||||
} from "../util/stateManager";
|
||||
|
||||
const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
||||
switch (action.type) {
|
||||
case "universe exists, but loading is still in progress": {
|
||||
const { universe } = action;
|
||||
return universe;
|
||||
}
|
||||
|
||||
case "universe: column load success": {
|
||||
const { dim, dataframe } = action;
|
||||
switch (dim) {
|
||||
case "obsAnnotations": {
|
||||
return {
|
||||
...state,
|
||||
...addObsAnnotations(state, dataframe),
|
||||
};
|
||||
}
|
||||
case "varAnnotations": {
|
||||
return {
|
||||
...state,
|
||||
...addVarAnnotations(state, dataframe),
|
||||
};
|
||||
}
|
||||
case "obsLayout": {
|
||||
return {
|
||||
...state,
|
||||
...addObsLayout(state, dataframe),
|
||||
};
|
||||
}
|
||||
default: {
|
||||
throw new Error("action handler not implemented");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "expression load success": {
|
||||
let { varData } = state;
|
||||
|
||||
// Lazy load new expression data into the varData dataframe, if
|
||||
// not already present.
|
||||
//
|
||||
Object.entries(action.expressionData).forEach(([key, val]) => {
|
||||
// If not already in universe.varData, save entire expression column
|
||||
if (!varData.hasCol(key)) {
|
||||
varData = varData.withCol(key, val);
|
||||
}
|
||||
});
|
||||
|
||||
// Prune size of varData "cache" if getting out of hand....
|
||||
//
|
||||
const { userDefinedGenes, diffexpGenes } = prevSharedState;
|
||||
const allTheGenesWeNeed = [
|
||||
...new Set(
|
||||
[userDefinedGenes, diffexpGenes, Object.keys(action.expressionData)]
|
||||
.filter((ele) => ele)
|
||||
.flat()
|
||||
),
|
||||
];
|
||||
varData = ControlsHelpers.pruneVarDataCache(varData, allTheGenesWeNeed);
|
||||
return { ...state, varData };
|
||||
}
|
||||
|
||||
case "annotation: create category": {
|
||||
/* create a new annotation category, with all values set to 'unassigned' */
|
||||
const name = action.data;
|
||||
const { categoryToDuplicate } = action;
|
||||
|
||||
/* name must be a string, non-zero length */
|
||||
if (typeof name !== "string" || name.length === 0)
|
||||
throw new Error("user annotations require string name");
|
||||
|
||||
/* ensure the name isn't already in use! */
|
||||
if (state.obsAnnotations.hasCol(name))
|
||||
throw new Error("name collision on annotation category create");
|
||||
|
||||
/* ensure the duplicate col exists */
|
||||
if (
|
||||
categoryToDuplicate &&
|
||||
!state.obsAnnotations.hasCol(categoryToDuplicate)
|
||||
)
|
||||
throw new Error("categoryToDuplicate does not exist");
|
||||
|
||||
let schema;
|
||||
let data;
|
||||
if (categoryToDuplicate) {
|
||||
/* duplicate the named annotation */
|
||||
schema = AnnotationsHelpers.dupObsAnnoSchema(
|
||||
state.schema,
|
||||
categoryToDuplicate,
|
||||
name,
|
||||
{
|
||||
writable: true,
|
||||
}
|
||||
);
|
||||
/* if we are duplicating a non-writable annotation, it may not have an unassigned category */
|
||||
const s = schema.annotations.obsByName[name];
|
||||
if (s.categories.indexOf(unassignedCategoryLabel) === -1) {
|
||||
s.categories = s.categories.concat(unassignedCategoryLabel);
|
||||
}
|
||||
data = state.obsAnnotations.col(categoryToDuplicate).asArray();
|
||||
} else {
|
||||
/* else, all are unassined */
|
||||
const categories = [unassignedCategoryLabel];
|
||||
schema = AnnotationsHelpers.addObsAnnoSchema(state.schema, name, {
|
||||
name,
|
||||
categories,
|
||||
type: "categorical",
|
||||
writable: true,
|
||||
});
|
||||
data = new Array(state.nObs).fill(unassignedCategoryLabel);
|
||||
}
|
||||
|
||||
const obsAnnotations = state.obsAnnotations.withCol(name, data);
|
||||
return { ...state, obsAnnotations, schema };
|
||||
}
|
||||
|
||||
case "annotation: category edited": {
|
||||
/* change the name of an obs annotation category */
|
||||
const name = action.metadataField;
|
||||
const newName = action.newCategoryText;
|
||||
if (!AnnotationsHelpers.isUserAnnotation(state, name))
|
||||
throw new Error("unable to edit read-only annotation");
|
||||
if (typeof newName !== "string" || newName.length === 0)
|
||||
throw new Error("user annotations require string name");
|
||||
|
||||
const colSchema = {
|
||||
...state.schema.annotations.obsByName[name],
|
||||
name: newName,
|
||||
};
|
||||
const schema = AnnotationsHelpers.addObsAnnoSchema(
|
||||
AnnotationsHelpers.removeObsAnnoSchema(state.schema, name),
|
||||
newName,
|
||||
colSchema
|
||||
);
|
||||
const obsAnnotations = state.obsAnnotations.renameCol(name, newName);
|
||||
return { ...state, schema, obsAnnotations };
|
||||
}
|
||||
|
||||
case "annotation: delete category": {
|
||||
/* delete annotation category from schema and obsAnnotations */
|
||||
const name = action.metadataField;
|
||||
if (!AnnotationsHelpers.isUserAnnotation(state, name))
|
||||
throw new Error("unable to delete read-only annotation");
|
||||
|
||||
const schema = AnnotationsHelpers.removeObsAnnoSchema(state.schema, name);
|
||||
const obsAnnotations = state.obsAnnotations.dropCol(name);
|
||||
return { ...state, schema, obsAnnotations };
|
||||
}
|
||||
|
||||
case "annotation: add new label to category": {
|
||||
const annotationName = action.metadataField;
|
||||
const newLabelName = action.newLabelText;
|
||||
if (!AnnotationsHelpers.isUserAnnotation(state, annotationName))
|
||||
throw new Error("unable to modify read-only annotation");
|
||||
if (typeof newLabelName !== "string" || newLabelName.length === 0)
|
||||
throw new Error(
|
||||
"user annotations require a non-zero length string name"
|
||||
);
|
||||
|
||||
/* add the new label to the annotation schema */
|
||||
const schema = AnnotationsHelpers.addObsAnnoCategory(
|
||||
state.schema,
|
||||
annotationName,
|
||||
newLabelName
|
||||
);
|
||||
|
||||
/* if so requested, label the current selection */
|
||||
const { world, crossfilter } = prevSharedState;
|
||||
const { metadataField, newLabelText } = action;
|
||||
const obsAnnotations = !action.assignSelectedCells
|
||||
? state.obsAnnotations
|
||||
: setLabelOnCurrentSelection(
|
||||
state,
|
||||
world,
|
||||
crossfilter,
|
||||
metadataField,
|
||||
newLabelText
|
||||
);
|
||||
|
||||
return { ...state, schema, obsAnnotations };
|
||||
}
|
||||
|
||||
case "annotation: label edited": {
|
||||
const annotationName = action.metadataField;
|
||||
const oldLabelName = action.label;
|
||||
const newLabelName = action.editedLabel;
|
||||
if (!AnnotationsHelpers.isUserAnnotation(state, annotationName))
|
||||
throw new Error("unable to modify read-only annotation");
|
||||
if (typeof newLabelName !== "string" || newLabelName.length === 0)
|
||||
throw new Error(
|
||||
"user annotations require a non-zero length string name"
|
||||
);
|
||||
|
||||
/* remove old label, add new label */
|
||||
const schema = AnnotationsHelpers.addObsAnnoCategory(
|
||||
AnnotationsHelpers.removeObsAnnoCategory(
|
||||
state.schema,
|
||||
annotationName,
|
||||
oldLabelName
|
||||
),
|
||||
annotationName,
|
||||
newLabelName
|
||||
);
|
||||
|
||||
/* change all values in obsAnnotation */
|
||||
const obsAnnotations = AnnotationsHelpers.setLabelByValue(
|
||||
state.obsAnnotations,
|
||||
annotationName,
|
||||
oldLabelName,
|
||||
newLabelName
|
||||
);
|
||||
|
||||
return { ...state, schema, obsAnnotations };
|
||||
}
|
||||
|
||||
case "annotation: delete label": {
|
||||
/* delete the label from the annotation, and set all cells with this value to unassigned */
|
||||
const annotationName = action.metadataField;
|
||||
const labelName = action.label;
|
||||
if (!AnnotationsHelpers.isUserAnnotation(state, annotationName))
|
||||
throw new Error("unable to modify read-only annotation");
|
||||
if (labelName === unassignedCategoryLabel)
|
||||
throw new Error("may not remove the unassigned label");
|
||||
|
||||
/* remove the category from the schema */
|
||||
const schema = AnnotationsHelpers.removeObsAnnoCategory(
|
||||
state.schema,
|
||||
annotationName,
|
||||
labelName
|
||||
);
|
||||
|
||||
/* set all values to unassigned in obsAnnotations */
|
||||
const obsAnnotations = AnnotationsHelpers.setLabelByValue(
|
||||
state.obsAnnotations,
|
||||
annotationName,
|
||||
labelName,
|
||||
unassignedCategoryLabel
|
||||
);
|
||||
|
||||
return { ...state, schema, obsAnnotations };
|
||||
}
|
||||
|
||||
case "annotation: label current cell selection": {
|
||||
const { metadataField, label } = action;
|
||||
const { world, crossfilter } = prevSharedState;
|
||||
const obsAnnotations = setLabelOnCurrentSelection(
|
||||
state,
|
||||
world,
|
||||
crossfilter,
|
||||
metadataField,
|
||||
label
|
||||
);
|
||||
return { ...state, obsAnnotations };
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function setLabelOnCurrentSelection(
|
||||
universe,
|
||||
world,
|
||||
crossfilter,
|
||||
metadataField,
|
||||
label
|
||||
) {
|
||||
/*
|
||||
Set category `metadataField` to value `label` for anything currently selected.
|
||||
Used by several action type reducers.
|
||||
|
||||
Returns the new obsAnnotations dataframe.
|
||||
*/
|
||||
|
||||
/*
|
||||
selection state is relative to world. We need to convert it
|
||||
to a mask for Universe before applying it.
|
||||
*/
|
||||
const worldMask = crossfilter.allSelectedMask();
|
||||
const mask = World.worldEqUniverse(world, universe)
|
||||
? worldMask
|
||||
: AnnotationsHelpers.worldToUniverseMask(
|
||||
worldMask,
|
||||
world.obsAnnotations,
|
||||
universe.nObs
|
||||
);
|
||||
const obsAnnotations = AnnotationsHelpers.setLabelByMask(
|
||||
universe.obsAnnotations,
|
||||
metadataField,
|
||||
mask,
|
||||
label
|
||||
);
|
||||
return obsAnnotations;
|
||||
}
|
||||
|
||||
export default Universe;
|
||||
@@ -1,370 +0,0 @@
|
||||
import { unassignedCategoryLabel } from "../globals";
|
||||
import {
|
||||
World,
|
||||
ControlsHelpers,
|
||||
AnnotationsHelpers,
|
||||
} from "../util/stateManager";
|
||||
import {
|
||||
addObsLayout,
|
||||
removeObsLayout,
|
||||
} from "../util/stateManager/schemaHelpers";
|
||||
import clip from "../util/clip";
|
||||
import quantile from "../util/quantile";
|
||||
|
||||
/*
|
||||
important note: much of this code assumes that wriable (user) annotations
|
||||
will NOT contain scalar data (ie, will only contain categorical labelled
|
||||
data), and therefore will never need to be clipped. Put another way, it
|
||||
assumes that for these annotations, the clipped & unclipped data is equal.
|
||||
|
||||
If we ever start allowing user editable scalar data, this assumption will
|
||||
need to be revisited.
|
||||
*/
|
||||
|
||||
const WorldReducer = (
|
||||
state = null,
|
||||
action,
|
||||
nextSharedState,
|
||||
prevSharedState
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "universe exists, but loading is still in progress":
|
||||
case "reset World to eq Universe": {
|
||||
const { universe } = nextSharedState;
|
||||
const world = World.createWorldFromEntireUniverse(universe);
|
||||
return world;
|
||||
}
|
||||
|
||||
case "universe: column load success": {
|
||||
/* incremental initial data load - always assumes world == universe */
|
||||
const { universe } = nextSharedState;
|
||||
const { dim } = action;
|
||||
|
||||
// we don't clip anything except for varData and obsAnnotations
|
||||
let { unclipped } = state;
|
||||
if (dim === "varData" || dim === "obsAnnotations") {
|
||||
unclipped = {
|
||||
...unclipped,
|
||||
[dim]: universe[dim].clone(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
schema: universe.schema,
|
||||
[dim]: universe[dim].clone(),
|
||||
unclipped,
|
||||
};
|
||||
}
|
||||
|
||||
case "set World to current selection": {
|
||||
/* Set viewable world to be the currently selected data */
|
||||
const world = World.createWorldBySelection(
|
||||
action.universe,
|
||||
action.world,
|
||||
action.crossfilter
|
||||
);
|
||||
return world;
|
||||
}
|
||||
|
||||
case "set clip quantiles": {
|
||||
const world = World.createWorldWithNewClip(
|
||||
prevSharedState.universe,
|
||||
state,
|
||||
prevSharedState.crossfilter,
|
||||
action.clipQuantiles
|
||||
);
|
||||
return world;
|
||||
}
|
||||
|
||||
case "expression load success": {
|
||||
const { universe } = nextSharedState;
|
||||
const universeVarData = universe.varData;
|
||||
let unclippedVarData = state.unclipped.varData;
|
||||
|
||||
// Lazy load new expression data into the unclipped varData dataframe, if
|
||||
// not already present.
|
||||
//
|
||||
Object.entries(action.expressionData).forEach(([key, val]) => {
|
||||
// If not already in world.varData, save sliced expression column
|
||||
if (!unclippedVarData.hasCol(key)) {
|
||||
// Slice if world !== universe, else just use whole column.
|
||||
// Use the obsAnnotation index as the cut key, as we keep
|
||||
// all world dataframes in sync.
|
||||
let worldValSlice = val;
|
||||
if (!World.worldEqUniverse(state, universe)) {
|
||||
worldValSlice = universeVarData
|
||||
.subset(state.obsAnnotations.rowIndex.labels(), [key], null)
|
||||
.icol(0)
|
||||
.asArray();
|
||||
}
|
||||
|
||||
// Now build world's varData dataframe
|
||||
unclippedVarData = unclippedVarData.withCol(
|
||||
key,
|
||||
worldValSlice,
|
||||
state.obsAnnotations.rowIndex
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Prune size of varData unclipped dataframe if getting out of hand....
|
||||
//
|
||||
const { userDefinedGenes, diffexpGenes } = prevSharedState;
|
||||
const allTheGenesWeNeed = [
|
||||
...new Set(
|
||||
[userDefinedGenes, diffexpGenes, Object.keys(action.expressionData)]
|
||||
.filter((ele) => ele)
|
||||
.flat()
|
||||
),
|
||||
];
|
||||
unclippedVarData = ControlsHelpers.pruneVarDataCache(
|
||||
unclippedVarData,
|
||||
allTheGenesWeNeed
|
||||
);
|
||||
|
||||
// at this point, we have the unclipped data in unclippedVarData.
|
||||
// Now create clipped.
|
||||
// - Drop columns no longer needed
|
||||
// - Add new columns
|
||||
//
|
||||
let clippedVarData = state.varData;
|
||||
const keysToDrop = clippedVarData.colIndex
|
||||
.labels()
|
||||
.filter((k) => !unclippedVarData.hasCol(k));
|
||||
const keysToAdd = unclippedVarData.colIndex
|
||||
.labels()
|
||||
.filter((k) => !clippedVarData.hasCol(k));
|
||||
keysToDrop.forEach((k) => {
|
||||
clippedVarData = clippedVarData.dropCol(k);
|
||||
});
|
||||
keysToAdd.forEach((k) => {
|
||||
const data = unclippedVarData.col(k).asArray();
|
||||
const q = [state.clipQuantiles.min, state.clipQuantiles.max];
|
||||
const [qMinVal, qMaxVal] = quantile(q, data);
|
||||
const clippedData = clip(data, qMinVal, qMaxVal, Number.NaN);
|
||||
clippedVarData = clippedVarData.withCol(
|
||||
k,
|
||||
clippedData,
|
||||
state.obsAnnotations.rowIndex
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
varData: clippedVarData,
|
||||
unclipped: {
|
||||
...state.unclipped,
|
||||
varData: unclippedVarData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "annotation: create category": {
|
||||
const name = action.data;
|
||||
const { universe } = nextSharedState;
|
||||
const { schema } = universe;
|
||||
|
||||
/*
|
||||
if world !== universe, we have to subset the newly created annotation,
|
||||
else, just use it as is.
|
||||
*/
|
||||
let newAnnotation = null;
|
||||
if (!World.worldEqUniverse(state, universe)) {
|
||||
newAnnotation = universe.obsAnnotations
|
||||
.subset(state.obsAnnotations.rowIndex.labels(), [name], null)
|
||||
.icol(0)
|
||||
.asArray();
|
||||
} else {
|
||||
newAnnotation = universe.obsAnnotations.col(name).asArray();
|
||||
}
|
||||
const obsAnnotations = state.obsAnnotations.withCol(
|
||||
name,
|
||||
newAnnotation,
|
||||
state.obsAnnotations.rowIndex
|
||||
);
|
||||
const unclipped = {
|
||||
...state.unclipped,
|
||||
obsAnnotations: state.unclipped.obsAnnotations.withCol(
|
||||
name,
|
||||
newAnnotation,
|
||||
state.unclipped.obsAnnotations.rowIndex
|
||||
),
|
||||
};
|
||||
return { ...state, schema, obsAnnotations, unclipped };
|
||||
}
|
||||
|
||||
case "annotation: category edited": {
|
||||
/* change the name of an obs annotation */
|
||||
const name = action.metadataField;
|
||||
const newName = action.newCategoryText;
|
||||
const { schema } = nextSharedState.universe;
|
||||
const obsAnnotations = state.obsAnnotations.renameCol(name, newName);
|
||||
const unclipped = {
|
||||
...state.unclipped,
|
||||
obsAnnotations: state.unclipped.obsAnnotations.renameCol(name, newName),
|
||||
};
|
||||
return { ...state, schema, obsAnnotations, unclipped };
|
||||
}
|
||||
|
||||
case "annotation: delete category": {
|
||||
/* remove a category from obs annotation */
|
||||
const { schema } = nextSharedState.universe;
|
||||
const name = action.metadataField;
|
||||
const obsAnnotations = state.obsAnnotations.dropCol(name);
|
||||
const unclipped = {
|
||||
...state.unclipped,
|
||||
obsAnnotations: state.unclipped.obsAnnotations.dropCol(name),
|
||||
};
|
||||
return { ...state, schema, obsAnnotations, unclipped };
|
||||
}
|
||||
|
||||
case "annotation: add new label to category": {
|
||||
/* add a new label to the schema - schema updated by universe reducer, we just need to note it */
|
||||
const { schema } = nextSharedState.universe;
|
||||
const { metadataField, newLabelText } = action;
|
||||
const { crossfilter } = prevSharedState;
|
||||
|
||||
if (action.assignSelectedCells) {
|
||||
return {
|
||||
...state,
|
||||
schema,
|
||||
...setLabelOnCurrentSelection(
|
||||
state,
|
||||
crossfilter,
|
||||
metadataField,
|
||||
newLabelText
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ...state, schema };
|
||||
}
|
||||
|
||||
case "annotation: label edited": {
|
||||
const { schema } = nextSharedState.universe;
|
||||
const { metadataField } = action;
|
||||
const oldLabelName = action.label;
|
||||
const newLabelName = action.editedLabel;
|
||||
|
||||
/* set all values to to new label */
|
||||
const unclipped = {
|
||||
...state.unclipped,
|
||||
obsAnnotations: AnnotationsHelpers.setLabelByValue(
|
||||
state.unclipped.obsAnnotations,
|
||||
metadataField,
|
||||
oldLabelName,
|
||||
newLabelName
|
||||
),
|
||||
};
|
||||
const obsAnnotations = state.obsAnnotations.replaceColData(
|
||||
metadataField,
|
||||
unclipped.obsAnnotations.col(metadataField).asArray()
|
||||
);
|
||||
return { ...state, schema, obsAnnotations, unclipped };
|
||||
}
|
||||
|
||||
case "annotation: delete label": {
|
||||
const { schema } = nextSharedState.universe;
|
||||
const { label, metadataField } = action;
|
||||
|
||||
/* set all values to unassigned in obsAnnotations */
|
||||
const unclipped = {
|
||||
...state.unclipped,
|
||||
obsAnnotations: AnnotationsHelpers.setLabelByValue(
|
||||
state.unclipped.obsAnnotations,
|
||||
metadataField,
|
||||
label,
|
||||
unassignedCategoryLabel
|
||||
),
|
||||
};
|
||||
const obsAnnotations = state.obsAnnotations.replaceColData(
|
||||
metadataField,
|
||||
unclipped.obsAnnotations.col(metadataField).asArray()
|
||||
);
|
||||
return { ...state, schema, obsAnnotations, unclipped };
|
||||
}
|
||||
|
||||
case "annotation: label current cell selection": {
|
||||
const { metadataField, label } = action;
|
||||
const { crossfilter } = prevSharedState;
|
||||
return {
|
||||
...state,
|
||||
...setLabelOnCurrentSelection(state, crossfilter, metadataField, label),
|
||||
};
|
||||
}
|
||||
|
||||
case "reembed: add reembedding": {
|
||||
// new embedding loaded, which *only* affects world's layout.
|
||||
// It may be new, or it may replace a previous re-embedding.
|
||||
const { obsLayout: origObsLayout, schema: origSchema } = state;
|
||||
const { embedding, schema: embeddingSchema } = action;
|
||||
|
||||
const { dims } = embeddingSchema;
|
||||
let obsLayout = origObsLayout;
|
||||
let schema = origSchema;
|
||||
|
||||
// alias the names the server sent us, in case they were not the same as the schema
|
||||
const embedingLabels = embedding.colIndex.labels();
|
||||
const labels = {
|
||||
[embedingLabels[0]]: dims[0],
|
||||
[embedingLabels[1]]: dims[1],
|
||||
};
|
||||
obsLayout = obsLayout.withColsFrom(embedding, labels);
|
||||
schema = addObsLayout(schema, embeddingSchema);
|
||||
return {
|
||||
...state,
|
||||
obsLayout,
|
||||
schema,
|
||||
};
|
||||
}
|
||||
|
||||
case "reembed: clear all reembeddings": {
|
||||
// reembedding was cleared -- remove from layout
|
||||
const { obsLayout: origObsLayout, schema: origSchema } = state;
|
||||
const { reembedding } = prevSharedState;
|
||||
|
||||
let schema = origSchema;
|
||||
let obsLayout = origObsLayout;
|
||||
|
||||
reembedding.reembeddings.forEach((emb, name) => {
|
||||
const { dims } = emb.schema;
|
||||
obsLayout = obsLayout.dropCol(dims[0]);
|
||||
obsLayout = obsLayout.dropCol(dims[1]);
|
||||
schema = removeObsLayout(schema, name);
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
obsLayout,
|
||||
schema,
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function setLabelOnCurrentSelection(world, crossfilter, metadataField, label) {
|
||||
/*
|
||||
Set category `metadataField` to value `label` for anything currently selected.
|
||||
Used by several action type reducers.
|
||||
*/
|
||||
const mask = crossfilter.allSelectedMask();
|
||||
const unclipped = {
|
||||
...world.unclipped,
|
||||
obsAnnotations: AnnotationsHelpers.setLabelByMask(
|
||||
world.unclipped.obsAnnotations,
|
||||
metadataField,
|
||||
mask,
|
||||
label
|
||||
),
|
||||
};
|
||||
const obsAnnotations = world.obsAnnotations.replaceColData(
|
||||
metadataField,
|
||||
unclipped.obsAnnotations.col(metadataField).asArray()
|
||||
);
|
||||
|
||||
return { obsAnnotations, unclipped };
|
||||
}
|
||||
|
||||
export default WorldReducer;
|
||||
@@ -6,8 +6,14 @@ import { postNetworkErrorToast } from "../components/framework/toasters";
|
||||
dispatch an action error to the user. Currently we use
|
||||
async toasts.
|
||||
*/
|
||||
export const dispatchNetworkErrorMessageToUser = (message) =>
|
||||
postNetworkErrorToast(message);
|
||||
let networkErrorToastKey = null;
|
||||
export const dispatchNetworkErrorMessageToUser = (message) => {
|
||||
if (!networkErrorToastKey) {
|
||||
networkErrorToastKey = postNetworkErrorToast(message);
|
||||
} else {
|
||||
postNetworkErrorToast(message, networkErrorToastKey);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Catch unexpected errors and make sure we don't lose them!
|
||||
|
||||
+44
-13
@@ -2,7 +2,7 @@ import quantile from "./quantile";
|
||||
import { memoize } from "./dataframe/util";
|
||||
import { unassignedCategoryLabel } from "../globals";
|
||||
import {
|
||||
createCategorySummary,
|
||||
createCategorySummaryFromDfCol,
|
||||
isSelectableCategoryName,
|
||||
} from "./stateManager/controlsHelpers";
|
||||
|
||||
@@ -22,19 +22,29 @@ label -> {
|
||||
yCoordinates: Float32Array
|
||||
}
|
||||
*/
|
||||
const getCoordinatesByLabel = (world, categoryName, layoutDimNames) => {
|
||||
const getCoordinatesByLabel = (
|
||||
schema,
|
||||
categoryName,
|
||||
categoryDf,
|
||||
layoutChoice,
|
||||
layoutDf
|
||||
) => {
|
||||
const coordsByCategoryLabel = new Map();
|
||||
// If the coloredBy is not a categorical col
|
||||
if (!isSelectableCategoryName(world.schema, categoryName)) {
|
||||
if (!isSelectableCategoryName(schema, categoryName)) {
|
||||
return coordsByCategoryLabel;
|
||||
}
|
||||
|
||||
const { obsAnnotations, obsLayout } = world;
|
||||
const categoryArray = obsAnnotations.col(categoryName).asArray();
|
||||
const layoutXArray = obsLayout.col(layoutDimNames[0]).asArray();
|
||||
const layoutYArray = obsLayout.col(layoutDimNames[1]).asArray();
|
||||
const categoryArray = categoryDf.col(categoryName).asArray();
|
||||
const layoutDimNames = layoutChoice.currentDimNames;
|
||||
const layoutXArray = layoutDf.col(layoutDimNames[0]).asArray();
|
||||
const layoutYArray = layoutDf.col(layoutDimNames[1]).asArray();
|
||||
|
||||
const categorySummary = createCategorySummaryFromDfCol(
|
||||
categoryDf.col(categoryName),
|
||||
schema.annotations.obsByName[categoryName]
|
||||
);
|
||||
|
||||
const categorySummary = createCategorySummary(world, categoryName);
|
||||
const {
|
||||
isUserAnno,
|
||||
categoryValueIndices,
|
||||
@@ -93,9 +103,21 @@ const getCoordinatesByLabel = (world, categoryName, layoutDimNames) => {
|
||||
label -> [x-Coordinate, y-Coordinate]
|
||||
*/
|
||||
|
||||
const calcMedianCentroid = (world, categoryName, layoutDimNames) => {
|
||||
const calcMedianCentroid = (
|
||||
schema,
|
||||
categoryName,
|
||||
categoryDf,
|
||||
layoutChoice,
|
||||
layoutDf
|
||||
) => {
|
||||
// generate a map describing the coordinates for each label within the given category
|
||||
const dataMap = getCoordinatesByLabel(world, categoryName, layoutDimNames);
|
||||
const dataMap = getCoordinatesByLabel(
|
||||
schema,
|
||||
categoryName,
|
||||
categoryDf,
|
||||
layoutChoice,
|
||||
layoutDf
|
||||
);
|
||||
|
||||
// label => [medianXCoordinate, medianYCoordinate]
|
||||
const coordinates = new Map();
|
||||
@@ -120,9 +142,18 @@ const calcMedianCentroid = (world, categoryName, layoutDimNames) => {
|
||||
};
|
||||
|
||||
// A simple function to hash the parameters
|
||||
const hashMedianCentroid = (world, categoryName, layoutDimNames) => {
|
||||
const { obsAnnotations, obsLayout } = world;
|
||||
return `${obsAnnotations.__id}+${obsLayout.__id}:${categoryName}:${layoutDimNames}`;
|
||||
const hashMedianCentroid = (
|
||||
schema,
|
||||
categoryName,
|
||||
categoryDf,
|
||||
layoutChoice,
|
||||
layoutDf
|
||||
) => {
|
||||
const category = categoryDf.col(categoryName);
|
||||
const layoutDimNames = layoutChoice.currentDimNames;
|
||||
const layoutX = layoutDf.col(layoutDimNames[0]);
|
||||
const layoutY = layoutDf.col(layoutDimNames[1]);
|
||||
return `${category.__id}+${layoutX.__id}:${layoutY.__id}`;
|
||||
};
|
||||
// export the memoized calculation function
|
||||
export default memoize(calcMedianCentroid, hashMedianCentroid);
|
||||
|
||||
@@ -557,12 +557,14 @@ class Dataframe {
|
||||
const dims = [...this.dims];
|
||||
|
||||
/* subset columns */
|
||||
let { __columns, colIndex } = this;
|
||||
let { __columns, colIndex, __columnsAccessor } = this;
|
||||
if (newColIndex) {
|
||||
const colOffsets = this.colIndex.getOffsets(newColIndex.labels());
|
||||
__columns = new Array(colOffsets.length);
|
||||
__columnsAccessor = new Array(colOffsets.length);
|
||||
for (let i = 0, l = colOffsets.length; i < l; i += 1) {
|
||||
__columns[i] = this.__columns[colOffsets[i]];
|
||||
__columnsAccessor[i] = this.__columnsAccessor[colOffsets[i]];
|
||||
}
|
||||
colIndex = newColIndex;
|
||||
dims[1] = colOffsets.length;
|
||||
@@ -580,10 +582,17 @@ class Dataframe {
|
||||
});
|
||||
rowIndex = newRowIndex;
|
||||
dims[0] = rowOffsets.length;
|
||||
__columnsAccessor = []; // force a recompile
|
||||
}
|
||||
|
||||
if (dims[0] === 0 || dims[1] === 0) return Dataframe.empty();
|
||||
return new Dataframe(dims, __columns, rowIndex, colIndex);
|
||||
return new Dataframe(
|
||||
dims,
|
||||
__columns,
|
||||
rowIndex,
|
||||
colIndex,
|
||||
__columnsAccessor
|
||||
);
|
||||
}
|
||||
|
||||
subset(rowLabels, colLabels = null, withRowIndex = null) {
|
||||
@@ -792,7 +801,9 @@ class Dataframe {
|
||||
|
||||
callback MUST not modify the column, but instead return a mutated copy.
|
||||
*/
|
||||
const columns = this.__columns.map(callback);
|
||||
const columns = this.__columns.map((colData, colIdx) =>
|
||||
callback(colData, colIdx, this)
|
||||
);
|
||||
const columnsAccessor = columns.map((c, idx) =>
|
||||
this.__columns[idx] === c ? this.__columnsAccessor[idx] : undefined
|
||||
);
|
||||
|
||||
@@ -98,6 +98,23 @@ class IdentityInt32Index {
|
||||
return this.subset(offsets);
|
||||
}
|
||||
|
||||
/* identity index - labels are offsets */
|
||||
isubsetMask(mask) {
|
||||
let count = 0;
|
||||
if (mask.length !== this.maxOffset) {
|
||||
throw new RangeError("mask has invalid length for index");
|
||||
}
|
||||
let labels = new Int32Array(mask.length);
|
||||
for (let i = 0, l = mask.length; i < l; i += 1) {
|
||||
if (mask[i]) {
|
||||
labels[count] = i;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
labels = labels.slice(0, count);
|
||||
return this.subset(labels);
|
||||
}
|
||||
|
||||
withLabel(label) {
|
||||
if (label === this.maxOffset) {
|
||||
return new IdentityInt32Index(label + 1);
|
||||
@@ -201,7 +218,6 @@ class DenseInt32Index {
|
||||
if (offset === undefined || offset === -1)
|
||||
throw new RangeError(`unknown label: ${label}`);
|
||||
}
|
||||
|
||||
return this.__promote(labels);
|
||||
}
|
||||
|
||||
@@ -216,7 +232,22 @@ class DenseInt32Index {
|
||||
throw new RangeError(`out of bounds offset: ${offset}`);
|
||||
labels[i] = rindex[offset];
|
||||
}
|
||||
return this.__promote(labels);
|
||||
}
|
||||
|
||||
isubsetMask(mask) {
|
||||
const { rindex } = this;
|
||||
if (mask.length !== rindex.length)
|
||||
throw new RangeError("mask has invalid length for index");
|
||||
let count = 0;
|
||||
let labels = new Int32Array(mask.length);
|
||||
for (let i = 0, l = mask.length; i < l; i += 1) {
|
||||
if (mask[i]) {
|
||||
labels[count] = rindex[i];
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
labels = labels.slice(0, count);
|
||||
return this.__promote(labels);
|
||||
}
|
||||
|
||||
@@ -314,6 +345,22 @@ class KeyIndex {
|
||||
return new KeyIndex(labels);
|
||||
}
|
||||
|
||||
isubsetMask(mask) {
|
||||
const { rindex } = this;
|
||||
if (mask.length !== rindex.length)
|
||||
throw new RangeError("mask has invalid length for index");
|
||||
let labels = new Array(mask.length);
|
||||
let count = 0;
|
||||
for (let i = 0, l = mask.length; i < l; i += 1) {
|
||||
if (mask[i]) {
|
||||
labels[count] = rindex[i];
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
labels = labels.slice(0, count);
|
||||
return new KeyIndex(labels);
|
||||
}
|
||||
|
||||
withLabel(label) {
|
||||
return new KeyIndex([...this.rindex, label]);
|
||||
}
|
||||
|
||||
@@ -12,19 +12,58 @@ return Promise.all([
|
||||
plimit.add(() => fetch('/bar')),
|
||||
plimit.add(() => fetch('/baz'))
|
||||
])
|
||||
|
||||
if you want a priority queue based implementation, just
|
||||
use priorityAdd() instead of add():
|
||||
|
||||
const plimit = new PromiseLimit(2);
|
||||
return Promise.all([
|
||||
plimit.priorityAdd(0, () => fetch('/foo')),
|
||||
plimit.priorityAdd(10, () => fetch('/bar')),
|
||||
plimit.priorityAdd(-1, () => fetch('/baz'))
|
||||
])
|
||||
|
||||
Priority is a numeric value. Lower first. Stable ordering.
|
||||
*/
|
||||
|
||||
import TinyQueue from "tinyqueue";
|
||||
|
||||
function compare(a, b) {
|
||||
const diff = a.priority - b.priority;
|
||||
if (diff) return diff;
|
||||
return a.order - b.order;
|
||||
}
|
||||
|
||||
export default class PromiseLimit {
|
||||
constructor(maxConcurrency) {
|
||||
this.queue = new Set();
|
||||
constructor(maxConcurrency = 5) {
|
||||
this.queue = new TinyQueue([], compare);
|
||||
this.maxConcurrency = maxConcurrency;
|
||||
this.pending = 0;
|
||||
this.insertCounter = 0;
|
||||
}
|
||||
|
||||
priorityAdd(p, fn, ...args) {
|
||||
// p - numermic priority (lower first)
|
||||
// fn - must return a promise
|
||||
// args - will be passed to fn
|
||||
return this._push(p, fn, args);
|
||||
}
|
||||
|
||||
add(fn, ...args) {
|
||||
// fn - must return a promise
|
||||
// args - will be passed to fn
|
||||
return this._push(0, fn, args);
|
||||
}
|
||||
|
||||
/**
|
||||
Private below
|
||||
**/
|
||||
|
||||
_push(priority, fn, args) {
|
||||
const order = this.insertCount;
|
||||
this.insertCount += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.queue.add({ fn, args, resolve, reject });
|
||||
this.queue.push({ priority, order, fn, args, resolve, reject });
|
||||
this._resolveNext(false);
|
||||
});
|
||||
}
|
||||
@@ -32,15 +71,17 @@ export default class PromiseLimit {
|
||||
_resolveNext = (completed = true) => {
|
||||
if (completed) this.pending -= 1;
|
||||
|
||||
while (this.queue.size > 0 && this.pending < this.maxConcurrency) {
|
||||
const task = this.queue.values().next().value; // order of insertion
|
||||
while (this.queue.length > 0 && this.pending < this.maxConcurrency) {
|
||||
const task = this.queue.pop(); // order of insertion
|
||||
this.pending += 1;
|
||||
this.queue.delete(task);
|
||||
const { resolve, reject, fn, args } = task;
|
||||
|
||||
try {
|
||||
const result = fn(...args);
|
||||
result.then(this._resolveNext, this._resolveNext);
|
||||
result.then(
|
||||
() => this._resolveNext(true),
|
||||
() => this._resolveNext(true)
|
||||
);
|
||||
result.then(resolve, reject);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export default function renderThrottle(callback) {
|
||||
/*
|
||||
This wraps a call to requestAnimationFrame(), enforcing a single
|
||||
render callback at any given time (ie, you can call this any number
|
||||
of times, and it will coallesce multiple inter-frame calls into a
|
||||
single render).
|
||||
*/
|
||||
let rafCurrentlyInProgress = null;
|
||||
return function f() {
|
||||
if (rafCurrentlyInProgress) return;
|
||||
const context = this;
|
||||
rafCurrentlyInProgress = window.requestAnimationFrame(() => {
|
||||
callback.apply(context);
|
||||
rafCurrentlyInProgress = null;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -2,16 +2,13 @@
|
||||
Helper functions for user-editable annotations state management.
|
||||
See also reducers/annotations.js
|
||||
*/
|
||||
import { unassignedCategoryLabel } from "../../globals";
|
||||
import * as SchemaHelpers from "./schemaHelpers";
|
||||
import { obsAnnoDimensionName } from "../nameCreators";
|
||||
|
||||
/*
|
||||
There are a number of state constraints assumed throughout the
|
||||
application:
|
||||
- all obs annotations are in {world|universe}.obsAnnotations,
|
||||
- all obs annotations are in annoMatrix,
|
||||
regardless of whether or not they are user editable.
|
||||
- the {world|universe}.schema is always up to date and matches
|
||||
- the annoMatrix.schema is always up to date and matches
|
||||
the data
|
||||
- the schema flag `writable` correctly indicates whether
|
||||
the annotation is editable/mutable.
|
||||
@@ -31,105 +28,11 @@ export function isContinuousAnnotation(schema, name) {
|
||||
}
|
||||
|
||||
function _isUserAnnotation(schema, name) {
|
||||
return schema.annotations.obsByName[name]?.writable;
|
||||
return schema.annotations.obsByName[name]?.writable || false;
|
||||
}
|
||||
|
||||
export function isUserAnnotation(worldOrUniverse, name) {
|
||||
return _isUserAnnotation(worldOrUniverse.schema, name);
|
||||
}
|
||||
|
||||
export function removeObsAnnoSchema(schema, name) {
|
||||
/*
|
||||
remove named annotation from obs annotation schema
|
||||
*/
|
||||
|
||||
/* only remove if it exists and is a user annotation */
|
||||
if (!_isUserAnnotation(schema, name))
|
||||
throw new Error("removing non-user-defined schema");
|
||||
return SchemaHelpers.removeObsAnnoColumn(schema, name);
|
||||
}
|
||||
|
||||
export function addObsAnnoSchema(schema, name, colSchema) {
|
||||
/*
|
||||
add a categorical type to the obs annotation schema
|
||||
*/
|
||||
|
||||
/* collision detection */
|
||||
if (schema.annotations.obs.columns.some((v) => v.name === name))
|
||||
throw Error("annotations may not contain duplicate category names");
|
||||
if (name !== colSchema.name) throw Error("column schema does not match");
|
||||
return SchemaHelpers.addObsAnnoColumn(schema, name, colSchema);
|
||||
}
|
||||
|
||||
export function dupObsAnnoSchema(schema, sourceName, dupName, defaultSchema) {
|
||||
/*
|
||||
duplicate the obs annotation `sourceName` schema, but with the name `dupName`
|
||||
*/
|
||||
const colSchema = {
|
||||
...schema.annotations.obsByName[sourceName],
|
||||
...defaultSchema,
|
||||
name: dupName,
|
||||
};
|
||||
/* existance check */
|
||||
if (!colSchema) throw Error("source annotation does not exist");
|
||||
/* collision detection */
|
||||
if (schema.annotations.obs.columns.some((v) => v.name === dupName))
|
||||
throw Error("annotations may not contain duplicate category names");
|
||||
return SchemaHelpers.addObsAnnoColumn(schema, dupName, colSchema);
|
||||
}
|
||||
|
||||
export function removeObsAnnoCategory(schema, name, category) {
|
||||
/* don't allow deletion of unassigned category on writable annotations */
|
||||
|
||||
if (!_isUserAnnotation(schema, name))
|
||||
throw new Error("unable to modify read-only schema");
|
||||
if (category === unassignedCategoryLabel)
|
||||
throw new Error("may not remove unassigned category label");
|
||||
|
||||
return SchemaHelpers.removeObsAnnoCategory(schema, name, category);
|
||||
}
|
||||
|
||||
export function addObsAnnoCategory(schema, name, category) {
|
||||
if (!_isUserAnnotation(schema, name))
|
||||
throw new Error("unable to modify read-only schema");
|
||||
|
||||
return SchemaHelpers.addObsAnnoCategory(schema, name, category);
|
||||
}
|
||||
|
||||
export function setLabelByValue(df, colName, fromLabel, toLabel) {
|
||||
/*
|
||||
in the dataframe column `colName`, set any value of `fromLabel` to `toLabel`
|
||||
*/
|
||||
const keys = df.colIndex.labels();
|
||||
const ndf = df.mapColumns((col, colIdx) => {
|
||||
if (colName !== keys[colIdx]) return col;
|
||||
|
||||
/* clone data and return it. */
|
||||
const newCol = col.slice();
|
||||
for (let i = 0, l = newCol.length; i < l; i += 1) {
|
||||
if (newCol[i] === fromLabel) newCol[i] = toLabel;
|
||||
}
|
||||
return newCol;
|
||||
});
|
||||
return ndf;
|
||||
}
|
||||
|
||||
export function setLabelByMask(df, colName, mask, label) {
|
||||
/*
|
||||
in the dataframe column `colName`, set the masked rows to 'label'
|
||||
*/
|
||||
const keys = df.colIndex.labels();
|
||||
const ndf = df.mapColumns((col, colIdx) => {
|
||||
if (colName !== keys[colIdx]) return col;
|
||||
|
||||
/* clone data and return it. */
|
||||
const newCol = col.slice();
|
||||
for (let i = 0, l = newCol.length; i < l; i += 1) {
|
||||
if (mask[i]) newCol[i] = label;
|
||||
}
|
||||
return newCol;
|
||||
});
|
||||
return ndf;
|
||||
export function isUserAnnotation(annoMatrix, name) {
|
||||
return _isUserAnnotation(annoMatrix.schema, name);
|
||||
}
|
||||
|
||||
export function allHaveLabelByMask(df, colName, label, mask) {
|
||||
@@ -148,41 +51,6 @@ export function allHaveLabelByMask(df, colName, label, mask) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function worldToUniverseMask(worldMask, worldObsAnnotations, nObs) {
|
||||
/*
|
||||
given world seleciton mask, return a selection mask for entire universe
|
||||
that has same selection state.
|
||||
*/
|
||||
const mask = new Uint8Array(nObs);
|
||||
const { rowIndex } = worldObsAnnotations;
|
||||
|
||||
for (let i = 0, l = worldMask.length; i < l; i += 1) {
|
||||
if (worldMask[i]) {
|
||||
const label = rowIndex.getLabel(i);
|
||||
mask[label] = 1;
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
export function createWritableAnnotationDimensions(world, crossfilter) {
|
||||
const { obsAnnotations, schema } = world;
|
||||
const writableAnnotations = schema.annotations.obs.columns
|
||||
.filter((s) => s.writable)
|
||||
.map((s) => s.name);
|
||||
|
||||
crossfilter = writableAnnotations.reduce((xflt, anno) => {
|
||||
const dimName = obsAnnoDimensionName(anno);
|
||||
if (xflt.hasDimension(dimName)) xflt = xflt.delDimension(dimName);
|
||||
return xflt.addDimension(
|
||||
dimName,
|
||||
"enum",
|
||||
obsAnnotations.col(anno).asArray()
|
||||
);
|
||||
}, crossfilter);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
const legalCharacters = /^(\w|[ .()-])+$/;
|
||||
export function annotationNameIsErroneous(name) {
|
||||
/*
|
||||
|
||||
@@ -3,46 +3,93 @@ Helper functions for the embedded graph colors
|
||||
*/
|
||||
import * as d3 from "d3";
|
||||
import { interpolateRainbow, interpolateCool } from "d3-scale-chromatic";
|
||||
import memoize from "memoize-one";
|
||||
import * as globals from "../../globals";
|
||||
import parseRGB from "../parseRGB";
|
||||
import finiteExtent from "../finiteExtent";
|
||||
import { range } from "../range";
|
||||
|
||||
/*
|
||||
create new colors state object. Paramters:
|
||||
- world - current world object
|
||||
- colorMode - color-by mode. One of {null, "color by expression", "color by continuous metadata",
|
||||
"color by categorical metadata"}
|
||||
- colorAccessor - the obs annotations used for color-by
|
||||
given a color mode & accessor, generate an annoMatrix query that will
|
||||
fulfill it
|
||||
*/
|
||||
export function createColors(
|
||||
world,
|
||||
colorMode = null,
|
||||
colorAccessor = null,
|
||||
export function createColorQuery(colorMode, colorByAccessor, schema) {
|
||||
if (!colorMode || !colorByAccessor || !schema) return null;
|
||||
switch (colorMode) {
|
||||
case "color by categorical metadata":
|
||||
case "color by continuous metadata": {
|
||||
return ["obs", colorByAccessor];
|
||||
}
|
||||
case "color by expression": {
|
||||
const varIndex = schema?.annotations?.var?.index;
|
||||
if (!varIndex) return null;
|
||||
return [
|
||||
"X",
|
||||
{
|
||||
field: "var",
|
||||
column: varIndex,
|
||||
value: colorByAccessor,
|
||||
},
|
||||
];
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _defaultColors(nObs) {
|
||||
const defaultCellColor = parseRGB(globals.defaultCellColor);
|
||||
return {
|
||||
rgb: new Array(nObs).fill(defaultCellColor),
|
||||
scale: undefined,
|
||||
};
|
||||
}
|
||||
const defaultColors = memoize(_defaultColors);
|
||||
|
||||
/*
|
||||
create colors scale and RGB array and return as object. Parameters:
|
||||
* colorMode - categorical, etc.
|
||||
* colorByAccessor - the annotation label name
|
||||
* colorByDataframe - the actual color-by data
|
||||
* schema - the entire schema
|
||||
* userColors - optional user color table
|
||||
Returns:
|
||||
{
|
||||
scale: color scale
|
||||
rgb: cell to color mapping
|
||||
}
|
||||
*/
|
||||
function _createColorTable(
|
||||
colorMode,
|
||||
colorByAccessor,
|
||||
colorByData,
|
||||
schema,
|
||||
userColors = null
|
||||
) {
|
||||
switch (colorMode) {
|
||||
case "color by categorical metadata": {
|
||||
if (userColors && colorAccessor in userColors) {
|
||||
return createUserColors(world, colorAccessor, userColors);
|
||||
const data = colorByData.col(colorByAccessor).asArray();
|
||||
if (userColors && colorByAccessor in userColors) {
|
||||
return createUserColors(data, colorByAccessor, userColors);
|
||||
}
|
||||
return createColorsByCategoricalMetadata(world, colorAccessor);
|
||||
return createColorsByCategoricalMetadata(data, colorByAccessor, schema);
|
||||
}
|
||||
case "color by continuous metadata": {
|
||||
return createColorsByContinuousMetadata(world, colorAccessor);
|
||||
const col = colorByData.col(colorByAccessor);
|
||||
const { min, max } = col.summarize();
|
||||
return createColorsByContinuousMetadata(col.asArray(), min, max);
|
||||
}
|
||||
case "color by expression": {
|
||||
return createColorsByExpression(world, colorAccessor);
|
||||
const col = colorByData.icol(0);
|
||||
const { min, max } = col.summarize();
|
||||
return createColorsByContinuousMetadata(col.asArray(), min, max);
|
||||
}
|
||||
default: {
|
||||
const defaultCellColor = parseRGB(globals.defaultCellColor);
|
||||
return {
|
||||
rgb: new Array(world.nObs).fill(defaultCellColor),
|
||||
scale: undefined,
|
||||
};
|
||||
return defaultColors(schema.dataframe.nObs);
|
||||
}
|
||||
}
|
||||
}
|
||||
export const createColorTable = memoize(_createColorTable);
|
||||
|
||||
export function loadUserColorConfig(userColors) {
|
||||
const convertedUserColors = {};
|
||||
@@ -62,14 +109,15 @@ export function loadUserColorConfig(userColors) {
|
||||
return convertedUserColors;
|
||||
}
|
||||
|
||||
function createUserColors(world, colorAccessor, userColors) {
|
||||
function _createUserColors(data, colorAccessor, userColors) {
|
||||
const { colors, scale } = userColors[colorAccessor];
|
||||
const rgb = createRgbArray(world, colors, colorAccessor);
|
||||
const rgb = createRgbArray(data, colors);
|
||||
return { rgb, scale };
|
||||
}
|
||||
const createUserColors = memoize(_createUserColors);
|
||||
|
||||
function createColorsByCategoricalMetadata(world, colorAccessor) {
|
||||
const { categories } = world.schema.annotations.obsByName[colorAccessor];
|
||||
function _createColorsByCategoricalMetadata(data, colorAccessor, schema) {
|
||||
const { categories } = schema.annotations.obsByName[colorAccessor];
|
||||
|
||||
const scale = d3
|
||||
.scaleSequential(interpolateRainbow)
|
||||
@@ -81,25 +129,24 @@ function createColorsByCategoricalMetadata(world, colorAccessor) {
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const rgb = createRgbArray(world, colors, colorAccessor);
|
||||
const rgb = createRgbArray(data, colors);
|
||||
return { rgb, scale };
|
||||
}
|
||||
const createColorsByCategoricalMetadata = memoize(
|
||||
_createColorsByCategoricalMetadata
|
||||
);
|
||||
|
||||
export function createRgbArray(world, colors, colorAccessor) {
|
||||
const rgb = new Array(world.nObs);
|
||||
const df = world.obsAnnotations;
|
||||
const data = df.col(colorAccessor).asArray();
|
||||
for (let i = 0, len = df.length; i < len; i += 1) {
|
||||
function createRgbArray(data, colors) {
|
||||
const rgb = new Array(data.length);
|
||||
for (let i = 0, len = data.length; i < len; i += 1) {
|
||||
const label = data[i];
|
||||
rgb[i] = colors[label];
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
|
||||
function createColorsByContinuousMetadata(world, accessor) {
|
||||
function _createColorsByContinuousMetadata(data, min, max) {
|
||||
const colorBins = 100;
|
||||
const col = world.obsAnnotations.col(accessor);
|
||||
const { min, max } = col.summarize();
|
||||
const scale = d3
|
||||
.scaleQuantile()
|
||||
.domain([min, max])
|
||||
@@ -112,9 +159,8 @@ function createColorsByContinuousMetadata(world, accessor) {
|
||||
}
|
||||
|
||||
const nonFiniteColor = parseRGB(globals.nonFiniteCellColor);
|
||||
const rgb = new Array(world.nObs);
|
||||
const data = col.asArray();
|
||||
for (let i = 0, len = world.obsAnnotations.length; i < len; i += 1) {
|
||||
const rgb = new Array(data.length);
|
||||
for (let i = 0, len = data.length; i < len; i += 1) {
|
||||
const val = data[i];
|
||||
if (Number.isFinite(val)) {
|
||||
const c = scale(val);
|
||||
@@ -125,56 +171,6 @@ function createColorsByContinuousMetadata(world, accessor) {
|
||||
}
|
||||
return { rgb, scale };
|
||||
}
|
||||
|
||||
function createColorsByExpression(world, accessor) {
|
||||
const expression = world.varData.col(accessor).asArray();
|
||||
const colorBins = 100;
|
||||
const [min, max] = finiteExtent(expression);
|
||||
const scale = d3
|
||||
.scaleQuantile()
|
||||
.domain([min, max])
|
||||
.range(range(colorBins - 1, -1, -1));
|
||||
|
||||
/* pre-create colors - much faster than doing it for each obs */
|
||||
const colors = new Array(colorBins);
|
||||
for (let i = 0; i < colorBins; i += 1) {
|
||||
colors[i] = parseRGB(interpolateCool(i / colorBins));
|
||||
}
|
||||
const nonFiniteColor = parseRGB(globals.nonFiniteCellColor);
|
||||
|
||||
const rgb = new Array(world.nObs);
|
||||
for (let i = 0, len = expression.length; i < len; i += 1) {
|
||||
const e = expression[i];
|
||||
if (Number.isFinite(e)) {
|
||||
const c = scale(e);
|
||||
rgb[i] = colors[c];
|
||||
} else {
|
||||
rgb[i] = nonFiniteColor;
|
||||
}
|
||||
}
|
||||
return { rgb, scale };
|
||||
}
|
||||
|
||||
export const resetColors = (world) => {
|
||||
const { rgb, scale } = createColors(world);
|
||||
return {
|
||||
colorMode: null,
|
||||
colorAccessor: null,
|
||||
rgb,
|
||||
scale,
|
||||
};
|
||||
};
|
||||
|
||||
export const checkIfColorByDiffexpAndResetColors = (
|
||||
prevControls,
|
||||
state,
|
||||
prevWorld
|
||||
) => {
|
||||
if (prevControls.diffexpGenes.includes(state.colorAccessor)) {
|
||||
return {
|
||||
...state,
|
||||
...resetColors(prevWorld),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
export const createColorsByContinuousMetadata = memoize(
|
||||
_createColorsByContinuousMetadata
|
||||
);
|
||||
|
||||
@@ -7,10 +7,6 @@ import _ from "lodash";
|
||||
import * as globals from "../../globals";
|
||||
import { rangeFill as fillRange } from "../range";
|
||||
import fromEntries from "../fromEntries";
|
||||
import {
|
||||
userDefinedDimensionName,
|
||||
diffexpDimensionName,
|
||||
} from "../nameCreators";
|
||||
import { isCategoricalAnnotation } from "./annotationsHelpers";
|
||||
|
||||
/*
|
||||
@@ -40,7 +36,7 @@ Remember that option values can be ANY js type, except undefined/null.
|
||||
}
|
||||
*/
|
||||
function topNCategories(colSchema, summary, N) {
|
||||
/* return top N by occurrences in the data, preserving original category order */
|
||||
/* return top N by occurrences in the data */
|
||||
const { categories } = colSchema;
|
||||
const counts = categories.map((cat) => summary.categoryCounts.get(cat) ?? 0);
|
||||
|
||||
@@ -81,19 +77,16 @@ export function selectableCategoryNames(schema, names) {
|
||||
return names.filter((name) => isSelectableCategoryName(schema, name));
|
||||
}
|
||||
|
||||
export function createCategorySummary(world, name) {
|
||||
export function createCategorySummaryFromDfCol(dfCol, colSchema) {
|
||||
const N = globals.maxCategoricalOptionsToDisplay;
|
||||
const { obsAnnotations, schema } = world;
|
||||
|
||||
const colSchema = schema.annotations.obsByName[name];
|
||||
const { writable: isUserAnno } = colSchema;
|
||||
|
||||
/*
|
||||
Summarize the annotation data currently in world. Must return categoryValues
|
||||
in sorted order, and must include all category values even if they are not
|
||||
actively used in the current world.
|
||||
Summarize the annotation data currently in dataframe column. Must return
|
||||
categoryValues in sorted order, and must include all category values even
|
||||
if they are not actively used in the current annoMatrix view.
|
||||
*/
|
||||
const summary = obsAnnotations.col(name).summarizeCategorical();
|
||||
const summary = dfCol.summarizeCategorical();
|
||||
const [categoryValues, categoryValueCounts] = topNCategories(
|
||||
colSchema,
|
||||
summary,
|
||||
@@ -117,38 +110,6 @@ export function createCategoricalSelection(names) {
|
||||
return fromEntries(names.map((name) => [name, new Map()]));
|
||||
}
|
||||
|
||||
/*
|
||||
build a crossfilter dimensions for all gene expression related dimensions.
|
||||
*/
|
||||
export function createGeneDimensions(
|
||||
userDefinedGenes,
|
||||
diffexpGenes,
|
||||
world,
|
||||
crossfilter
|
||||
) {
|
||||
crossfilter = userDefinedGenes.reduce(
|
||||
(xflt, gene) =>
|
||||
xflt.addDimension(
|
||||
userDefinedDimensionName(gene),
|
||||
"scalar",
|
||||
world.varData.col(gene).asArray(),
|
||||
Float32Array
|
||||
),
|
||||
crossfilter
|
||||
);
|
||||
crossfilter = diffexpGenes.reduce(
|
||||
(xflt, gene) =>
|
||||
xflt.addDimension(
|
||||
diffexpDimensionName(gene),
|
||||
"scalar",
|
||||
world.varData.col(gene).asArray(),
|
||||
Float32Array
|
||||
),
|
||||
crossfilter
|
||||
);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
export function pruneVarDataCache(varData, needed) {
|
||||
/*
|
||||
Remove any unneeded columns from the varData dataframe. Will only
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
// jshint esversion: 6
|
||||
|
||||
/*
|
||||
Model manager providing an abstraction for the use of the reducer code.
|
||||
This module provides several buckets of functionality:
|
||||
- schema and config driven tranformation of the wire protocol
|
||||
into a format that is easy for the UI code to use.
|
||||
- manage the universe/world abstraction:
|
||||
+ universe: all of the server-provided, read-only data
|
||||
+ world: subset of universe
|
||||
- lazy access and caching of dataframe contents as needed
|
||||
|
||||
This is all VERY tightly integrated with reducers and actions, and
|
||||
exists to support those concepts.
|
||||
*/
|
||||
|
||||
export * as ColorHelpers from "./colorHelpers";
|
||||
export * as Universe from "./universe";
|
||||
export * as World from "./world";
|
||||
export * as ControlsHelpers from "./controlsHelpers";
|
||||
export * as AnnotationsHelpers from "./annotationsHelpers";
|
||||
export * as SchemaHelpers from "./schemaHelpers";
|
||||
|
||||
@@ -13,22 +13,21 @@ import catLabelSort from "../catLabelSort";
|
||||
System wide schema assumptions:
|
||||
- schema and data wil be consistent (eg, for user-created annotations)
|
||||
- schema will be internally self-consistent (eg, index matches columns)
|
||||
- world & universe schema are same - only data is subset
|
||||
*/
|
||||
|
||||
export function indexEntireSchema(schema) {
|
||||
/* Index schema for ease of use */
|
||||
schema.annotations.obsByName = fromEntries(
|
||||
schema.annotations.obs.columns.map((v) => [v.name, v])
|
||||
schema.annotations?.obs?.columns?.map((v) => [v.name, v]) ?? []
|
||||
);
|
||||
schema.annotations.varByName = fromEntries(
|
||||
schema.annotations.var.columns.map((v) => [v.name, v])
|
||||
schema.annotations?.var?.columns?.map((v) => [v.name, v]) ?? []
|
||||
);
|
||||
schema.layout.obsByName = fromEntries(
|
||||
schema.layout.obs.map((v) => [v.name, v])
|
||||
schema.layout?.obs?.map((v) => [v.name, v]) ?? []
|
||||
);
|
||||
schema.layout.varByName = fromEntries(
|
||||
schema.layout.var.map((v) => [v.name, v])
|
||||
schema.layout?.var?.map((v) => [v.name, v]) ?? []
|
||||
);
|
||||
|
||||
return schema;
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { unassignedCategoryLabel } from "../../globals";
|
||||
import { decodeMatrixFBS } from "./matrix";
|
||||
import * as Dataframe from "../dataframe";
|
||||
import { isFpTypedArray } from "../typeHelpers";
|
||||
import { indexEntireSchema } from "./schemaHelpers";
|
||||
import catLabelSort from "../catLabelSort";
|
||||
|
||||
/*
|
||||
Private helper function - create and return a template Universe
|
||||
*/
|
||||
function templateUniverse() {
|
||||
/* default universe template */
|
||||
return {
|
||||
nObs: 0,
|
||||
nVar: 0,
|
||||
schema: {},
|
||||
|
||||
/*
|
||||
annotations
|
||||
*/
|
||||
obsAnnotations: Dataframe.Dataframe.empty(),
|
||||
varAnnotations: Dataframe.Dataframe.empty(),
|
||||
/*
|
||||
layout
|
||||
*/
|
||||
obsLayout: Dataframe.Dataframe.empty(),
|
||||
|
||||
/*
|
||||
Var data columns - subset of all
|
||||
*/
|
||||
varData: Dataframe.Dataframe.empty(null, new Dataframe.KeyIndex()),
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
This module implements functions that support storage of "Universe",
|
||||
aka all of the var/obs data and annotations.
|
||||
|
||||
These functions are used exclusively by the actions and reducers to
|
||||
build an internal POJO for use by the rendering components.
|
||||
*/
|
||||
|
||||
export function createUniverseFromResponse(configResponse, schemaResponse) {
|
||||
/*
|
||||
build & return universe from a REST 0.2 /config, /schema and /annotations/obs response
|
||||
*/
|
||||
const { schema } = schemaResponse;
|
||||
const universe = templateUniverse();
|
||||
|
||||
/* schema related */
|
||||
universe.schema = schema;
|
||||
universe.nObs = schema.dataframe.nObs;
|
||||
universe.nVar = schema.dataframe.nVar;
|
||||
|
||||
/* add defaults, as we can't assume back-end will fully populate schema */
|
||||
if (!schema.layout.var) schema.layout.var = [];
|
||||
if (!schema.layout.obs) schema.layout.obs = [];
|
||||
indexEntireSchema(universe.schema);
|
||||
normalizeEntireSchema(universe.schema);
|
||||
|
||||
return universe;
|
||||
}
|
||||
|
||||
function normalizeSchemaCategory(colSchema, col = undefined) {
|
||||
const { type, writable } = colSchema;
|
||||
if (type === "string" || type === "boolean" || type === "categorical") {
|
||||
let categories = [
|
||||
...new Set([
|
||||
...(colSchema.categories ?? []),
|
||||
...(col?.summarize?.().categories ?? []),
|
||||
]),
|
||||
];
|
||||
if (writable && categories.indexOf(unassignedCategoryLabel) === -1) {
|
||||
categories = categories.concat(unassignedCategoryLabel);
|
||||
}
|
||||
colSchema.categories = categories;
|
||||
} else if (writable) {
|
||||
throw new Error(
|
||||
"Writable continuous obs annotations are not supported - failed to load"
|
||||
);
|
||||
}
|
||||
|
||||
if (colSchema.categories) {
|
||||
colSchema.categories = catLabelSort(writable, colSchema.categories);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEntireSchema(schema) {
|
||||
// currently only needed for obsAnnotations
|
||||
schema.annotations.obs.columns.forEach((colSchema) =>
|
||||
normalizeSchemaCategory(colSchema)
|
||||
);
|
||||
}
|
||||
|
||||
export function addObsAnnotations(universe, df) {
|
||||
const obsAnnotations = universe.obsAnnotations.withColsFromAll(df);
|
||||
if (universe.nObs !== obsAnnotations.length) {
|
||||
throw new Error("Universe dimensionality mismatch - failed to load");
|
||||
}
|
||||
|
||||
// for all of the new data, reconcile with schema and sort categories.
|
||||
const dfs = Array.isArray(df) ? df : [df];
|
||||
const keys = dfs.map((d) => d.colIndex.labels()).flat();
|
||||
const { schema } = universe;
|
||||
keys.forEach((k) => {
|
||||
const colSchema = schema.annotations.obsByName[k];
|
||||
const col = obsAnnotations.col(k);
|
||||
normalizeSchemaCategory(colSchema, col);
|
||||
});
|
||||
|
||||
return { obsAnnotations, schema };
|
||||
}
|
||||
|
||||
export function addVarAnnotations(universe, df) {
|
||||
const varAnnotations = universe.varAnnotations.withColsFromAll(df);
|
||||
if (universe.nVar !== varAnnotations.length) {
|
||||
throw new Error("Universe dimensionality mismatch - failed to load");
|
||||
}
|
||||
return { varAnnotations };
|
||||
}
|
||||
|
||||
export function addObsLayout(universe, df) {
|
||||
const obsLayout = universe.obsLayout.withColsFromAll(df);
|
||||
if (universe.nObs !== obsLayout.length) {
|
||||
throw new Error("Universe dimensionality mismatch - failed to load");
|
||||
}
|
||||
return { obsLayout };
|
||||
}
|
||||
|
||||
export function convertDataFBStoObject(universe, arrayBuffer) {
|
||||
/*
|
||||
/data/var returns a flatbuffer (FBS) as described by cellxgene/fbs/matrix.fbs
|
||||
|
||||
This routine converts the binary wire encoding into a JS object:
|
||||
|
||||
{
|
||||
gene: Float32Array,
|
||||
...
|
||||
}
|
||||
*/
|
||||
const fbs = decodeMatrixFBS(arrayBuffer);
|
||||
const { colIdx, columns } = fbs;
|
||||
const result = {};
|
||||
|
||||
if (!columns.every(isFpTypedArray)) {
|
||||
// We have strong assumptions that all var data is float
|
||||
throw new Error("Unexpected non-floating point response from server.");
|
||||
}
|
||||
|
||||
const varIndexName = universe.schema.annotations.var.index;
|
||||
for (let c = 0; c < colIdx.length; c += 1) {
|
||||
const varName = universe.varAnnotations.at(colIdx[c], varIndexName);
|
||||
result[varName] = columns[c];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
import clip from "../clip";
|
||||
import { layoutDimensionName, obsAnnoDimensionName } from "../nameCreators";
|
||||
import * as Dataframe from "../dataframe";
|
||||
import { isContinuousAnnotation } from "./annotationsHelpers";
|
||||
|
||||
/*
|
||||
|
||||
World is a subset of universe. Most code should use world, and should
|
||||
(generally) not use Universe. World contains any per-obs or per-var data
|
||||
that must be consistent acorss the app when we view/manipulate subsets
|
||||
of Universe.
|
||||
|
||||
Private API indicated by leading underscore in key name (eg, _foo). Anything else
|
||||
is public.
|
||||
|
||||
Notable keys in the world object:
|
||||
|
||||
* nObs, nVar: dimensions
|
||||
|
||||
* schema: data schema from the server
|
||||
|
||||
* clipQuantiles: the quantiles used to clip all data in world.
|
||||
|
||||
* obsAnnotations:
|
||||
|
||||
Dataframe containing obs annotations. Columns are indexed by annotation
|
||||
name (eg, 'tissue type'), and rows are indexed by the REST API obsIndex
|
||||
(ie, the offset into the underlying server-side dataframe).
|
||||
|
||||
This indexing means that you can access data by _either_ the server's
|
||||
obxIndex, or the offset into the client-side column array . Be careful
|
||||
to know which you want and are using.
|
||||
|
||||
* obsLayout:
|
||||
|
||||
A dataframe containing the X/Y layout for all obs. Columns are named
|
||||
'X' and 'Y', and rows are indexed in the same way as obsAnnotation.
|
||||
|
||||
* varData: a cache of expression columns, stored in a Dataframe. Cache
|
||||
managed by controls reducer.
|
||||
|
||||
* unclipped: will contain unclipped variants of all potentiall clipped
|
||||
dataframes (obsAnnotations, varData).
|
||||
|
||||
*/
|
||||
|
||||
function templateWorld() {
|
||||
const obsAnnotations = Dataframe.Dataframe.empty();
|
||||
const varAnnotations = Dataframe.Dataframe.empty();
|
||||
const obsLayout = Dataframe.Dataframe.empty();
|
||||
const varData = Dataframe.Dataframe.empty(null, new Dataframe.KeyIndex());
|
||||
return {
|
||||
/* schema/version related */
|
||||
schema: null,
|
||||
nObs: 0,
|
||||
nVar: 0,
|
||||
clipQuantiles: { min: 0, max: 1 },
|
||||
|
||||
/* annotations */
|
||||
obsAnnotations,
|
||||
varAnnotations,
|
||||
|
||||
/* layout of graph. Dataframe. */
|
||||
obsLayout,
|
||||
|
||||
/* Var data columns - subset of all data (may be empty) */
|
||||
varData,
|
||||
|
||||
/* unclipped dataframes - subset, but not value clipped */
|
||||
unclipped: {
|
||||
obsAnnotations,
|
||||
varData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function clipDataframe(
|
||||
df,
|
||||
lowerQuantile,
|
||||
upperQuantile,
|
||||
quantileF,
|
||||
clipPredicate = () => true,
|
||||
value = Number.NaN
|
||||
) {
|
||||
/*
|
||||
For all columns in the dataframe, clip all values above or below specified
|
||||
quantiles to `value` if clipPredicate returns True for that column (if it
|
||||
returns false, skip the column entirely).
|
||||
|
||||
Returns a clipped copy - does not mutate original.
|
||||
|
||||
clipPredicate must have signature: (dataframe, colIndex, colLabel) => boolean
|
||||
True signifies that the column should be clipped; false indicates that the
|
||||
column should be left intact/unchanged.
|
||||
|
||||
quantileF must have signature: (label, qval) => number
|
||||
*/
|
||||
if (lowerQuantile < 0) lowerQuantile = 0;
|
||||
if (upperQuantile > 1) upperQuantile = 1;
|
||||
if (lowerQuantile === 0 && upperQuantile === 1) return df;
|
||||
|
||||
const keys = df.colIndex.labels();
|
||||
return df.mapColumns((col, colIdx) => {
|
||||
const colLabel = keys[colIdx];
|
||||
if (!clipPredicate(df, colIdx, colLabel)) return col;
|
||||
|
||||
const colMin = quantileF(colLabel, lowerQuantile);
|
||||
const colMax = quantileF(colLabel, upperQuantile);
|
||||
const newCol = clip(col.slice(), colMin, colMax, value);
|
||||
return newCol;
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
Create World with contents eq entire universe. Commonly used to initialize World.
|
||||
*/
|
||||
export function createWorldFromEntireUniverse(universe) {
|
||||
const world = templateWorld();
|
||||
|
||||
/* Schema related */
|
||||
world.schema = universe.schema;
|
||||
world.nObs = universe.nObs;
|
||||
world.nVar = universe.nVar;
|
||||
world.clipQuantiles = { min: 0, max: 1 };
|
||||
|
||||
/* dataframes: annotations and layout */
|
||||
world.obsAnnotations = universe.obsAnnotations.clone();
|
||||
world.varAnnotations = universe.varAnnotations.clone();
|
||||
world.obsLayout = universe.obsLayout.clone();
|
||||
|
||||
/* Var dataframe - contains a subset of all var columns */
|
||||
world.varData = universe.varData.clone();
|
||||
|
||||
/* save unclipped copies of potentially clipped dataframes */
|
||||
world.unclipped = {
|
||||
obsAnnotations: world.obsAnnotations.clone(),
|
||||
varData: world.varData.clone(),
|
||||
};
|
||||
|
||||
return world;
|
||||
}
|
||||
|
||||
/*
|
||||
clip dataframes based on quantiles.
|
||||
|
||||
This is an in-place operation on the world object provided as an argument.
|
||||
The values in world.unclipped are clipped and assigned to world.obsAnnotations
|
||||
and world.varData.
|
||||
*/
|
||||
function setClippedDataframes(world) {
|
||||
const { schema } = world;
|
||||
const isContinuousObsAnnotation = (df, idx, label) =>
|
||||
isContinuousAnnotation(schema, label);
|
||||
const obsQuantile = (label, q) =>
|
||||
world.unclipped.obsAnnotations.col(label).summarize().percentiles[100 * q];
|
||||
world.obsAnnotations = clipDataframe(
|
||||
world.unclipped.obsAnnotations,
|
||||
world.clipQuantiles.min,
|
||||
world.clipQuantiles.max,
|
||||
obsQuantile,
|
||||
isContinuousObsAnnotation
|
||||
);
|
||||
|
||||
const varDataQuantile = (label, q) =>
|
||||
world.unclipped.varData.col(label).summarize().percentiles[100 * q];
|
||||
world.varData = clipDataframe(
|
||||
world.unclipped.varData,
|
||||
world.clipQuantiles.min,
|
||||
world.clipQuantiles.max,
|
||||
varDataQuantile,
|
||||
() => true
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
Subset the current world based upon the current selection, maintaining any existing
|
||||
clip. Returns new world. Parameters:
|
||||
* universe
|
||||
* world - the current world
|
||||
* crossfilter - the selection state
|
||||
*/
|
||||
export function createWorldBySelection(universe, world, crossfilter) {
|
||||
const newWorld = { ...world, obsLayout: null, unclipped: {}, varData: null };
|
||||
|
||||
/* subset unclipped dataframes based upon current selection */
|
||||
const mask = crossfilter.allSelectedMask();
|
||||
newWorld.obsLayout = world.obsLayout.isubsetMask(mask);
|
||||
newWorld.unclipped.obsAnnotations = world.unclipped.obsAnnotations.isubsetMask(
|
||||
mask
|
||||
);
|
||||
if (world.unclipped.varData.isEmpty()) {
|
||||
newWorld.unclipped.varData = world.unclipped.varData.clone();
|
||||
} else {
|
||||
newWorld.unclipped.varData = world.unclipped.varData.isubsetMask(mask);
|
||||
}
|
||||
/* subsetting changings dimension size */
|
||||
newWorld.nObs = newWorld.unclipped.obsAnnotations.dims[0];
|
||||
|
||||
/* and now clip */
|
||||
setClippedDataframes(newWorld);
|
||||
return newWorld;
|
||||
}
|
||||
|
||||
/*
|
||||
Change clip quantiles on the current world, returning a new world.
|
||||
Parameters:
|
||||
* universe
|
||||
* world - current world
|
||||
* clipQuantiles - new clip
|
||||
*/
|
||||
export function createWorldWithNewClip(
|
||||
universe,
|
||||
world,
|
||||
crossfilter,
|
||||
clipQuantiles
|
||||
) {
|
||||
const newWorld = { ...world, obsAnnotation: null, varData: null };
|
||||
newWorld.clipQuantiles = clipQuantiles;
|
||||
newWorld.obsLayout = world.obsLayout.clone();
|
||||
newWorld.unclipped = {
|
||||
obsAnnotations: world.unclipped.obsAnnotations.clone(),
|
||||
varData: world.unclipped.varData.clone(),
|
||||
};
|
||||
|
||||
/* and now clip */
|
||||
setClippedDataframes(newWorld);
|
||||
return newWorld;
|
||||
}
|
||||
|
||||
/*
|
||||
Deduce the correct crossfilter dimension type from a metadata
|
||||
schema description.
|
||||
*/
|
||||
function deduceDimensionType(attributes, fieldName) {
|
||||
let dimensionType;
|
||||
const { type } = attributes;
|
||||
if (type === "string" || type === "categorical" || type === "boolean") {
|
||||
dimensionType = "enum";
|
||||
} else if (type === "int32") {
|
||||
dimensionType = Int32Array;
|
||||
} else if (type === "float32") {
|
||||
dimensionType = Float32Array;
|
||||
} else {
|
||||
/*
|
||||
Currently not supporting boolean and categorical types.
|
||||
*/
|
||||
console.error(
|
||||
`Warning - REST API returned unknown metadata schema (${type}) for field ${fieldName}.`
|
||||
);
|
||||
// skip it - we don't know what to do with this type
|
||||
}
|
||||
return dimensionType;
|
||||
}
|
||||
|
||||
function addObsDimension(crossfilter, world, anno) {
|
||||
/*
|
||||
add single dimension to the crosfilter
|
||||
*/
|
||||
const { obsAnnotations } = world;
|
||||
if (obsAnnotations.hasCol(anno.name)) {
|
||||
const dimType = deduceDimensionType(anno, anno.name);
|
||||
const colData = obsAnnotations.col(anno.name).asArray();
|
||||
const name = obsAnnoDimensionName(anno.name);
|
||||
if (dimType === "enum") {
|
||||
return crossfilter.addDimension(name, "enum", colData);
|
||||
}
|
||||
if (dimType) {
|
||||
return crossfilter.addDimension(name, "scalar", colData, dimType);
|
||||
}
|
||||
}
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
export function addObsDimensions(crossfilter, world) {
|
||||
/*
|
||||
Add to crossfilter any dimension present in world.obsAnnotations
|
||||
but not yet in the crossfilter
|
||||
*/
|
||||
const schema = world.schema.annotations.obsByName;
|
||||
const dimsWeNeed = world.obsAnnotations.colIndex.labels();
|
||||
crossfilter = dimsWeNeed.reduce((xfltr, name) => {
|
||||
const dimName = obsAnnoDimensionName(name);
|
||||
if (xfltr.hasDimension(dimName)) return xfltr;
|
||||
return addObsDimension(xfltr, world, schema[name]);
|
||||
}, crossfilter);
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
export function createObsDimensions(crossfilter, world, XYdimNames) {
|
||||
/*
|
||||
create and return a crossfilter with a dimension for every obs annotation
|
||||
for which we have a supported type, *except* for the index column, indicated
|
||||
by schema.annotations.obs.index.
|
||||
*/
|
||||
const { schema, obsLayout } = world;
|
||||
const indexName = schema.annotations.obs.index;
|
||||
const annoList = schema.annotations.obs.columns.filter(
|
||||
(anno) => anno.name !== indexName
|
||||
);
|
||||
crossfilter = annoList.reduce((xfltr, anno) => {
|
||||
return addObsDimension(xfltr, world, anno);
|
||||
}, crossfilter);
|
||||
|
||||
return crossfilter.addDimension(
|
||||
layoutDimensionName("XY"),
|
||||
"spatial",
|
||||
obsLayout.col(XYdimNames[0]).asArray(),
|
||||
obsLayout.col(XYdimNames[1]).asArray()
|
||||
);
|
||||
}
|
||||
|
||||
export function worldEqUniverse(world, universe) {
|
||||
return (
|
||||
world.obsAnnotations === universe.obsAnnotations ||
|
||||
world.obsAnnotations.rowIndex === universe.obsAnnotations.rowIndex
|
||||
);
|
||||
}
|
||||
|
||||
export function getSelectedByIndex(crossfilter) {
|
||||
/*
|
||||
return array of obsIndex, containing all selected obs/cells.
|
||||
*/
|
||||
const selected = crossfilter.allSelectedMask(); // array of bool-ish
|
||||
const keys = crossfilter.data.rowIndex.labels(); // row keys, aka universe rowIndex
|
||||
|
||||
const set = new Int32Array(selected.length);
|
||||
let numElems = 0;
|
||||
for (let i = 0, l = selected.length; i < l; i += 1) {
|
||||
if (selected[i]) {
|
||||
set[numElems] = keys[i];
|
||||
numElems += 1;
|
||||
}
|
||||
}
|
||||
return new Int32Array(set.buffer, 0, numElems);
|
||||
}
|
||||
@@ -60,6 +60,9 @@ export default class ImmutableTypedCrossfilter {
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
if (this.data === data) return this;
|
||||
// please leave, WIP
|
||||
// console.log("...crossfilter set data, will drop cache");
|
||||
return new ImmutableTypedCrossfilter(data, this.dimensions);
|
||||
}
|
||||
|
||||
@@ -192,6 +195,7 @@ export default class ImmutableTypedCrossfilter {
|
||||
adds = PositiveIntervals.difference(newSeln.ranges, oldSeln.ranges);
|
||||
dels = PositiveIntervals.difference(oldSeln.ranges, newSeln.ranges);
|
||||
} else {
|
||||
// please leave, WIP
|
||||
// console.log("suboptimal selection update - index changed");
|
||||
adds = newSeln.ranges;
|
||||
dels = oldSeln.ranges;
|
||||
@@ -503,7 +507,10 @@ class ImmutableEnumDimension extends ImmutableScalarDimension {
|
||||
|
||||
selectExact(spec) {
|
||||
const { enumIndex } = this;
|
||||
const { values } = spec;
|
||||
let { values } = spec;
|
||||
if (!Array.isArray(values)) {
|
||||
values = [values];
|
||||
}
|
||||
return super.selectExact({
|
||||
mode: spec.mode,
|
||||
values: values.map((v) =>
|
||||
|
||||
Reference in New Issue
Block a user