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:
Bruce Martin
2020-07-14 13:53:33 -07:00
committed by GitHub
parent f69d141336
commit 1269e188be
95 changed files with 18051 additions and 5382 deletions
@@ -2,14 +2,14 @@
exports[`annotations stacked bar graph renders 1`] = `
Array [
"<div data-flip-config=\\"{&quot;translate&quot;:true,&quot;scale&quot;:true,&quot;opacity&quot;: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=\\"{&quot;translate&quot;:true,&quot;scale&quot;:true,&quot;opacity&quot;: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=\\"{&quot;translate&quot;:true,&quot;scale&quot;:true,&quot;opacity&quot;: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=\\"{&quot;translate&quot;:true,&quot;scale&quot;:true,&quot;opacity&quot;: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=\\"{&quot;translate&quot;:true,&quot;scale&quot;:true,&quot;opacity&quot;: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=\\"{&quot;translate&quot;:true,&quot;scale&quot;:true,&quot;opacity&quot;: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=\\"{&quot;translate&quot;:true,&quot;scale&quot;:true,&quot;opacity&quot;: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=\\"{&quot;translate&quot;:true,&quot;scale&quot;:true,&quot;opacity&quot;: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>",
]
`;
+1 -1
View File
@@ -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);
});
});
+30 -28
View File
@@ -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) => {
+315 -88
View File
@@ -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
+492 -828
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -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",
+374
View File
@@ -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
View File
@@ -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,
};
+2
View File
@@ -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",
});
}
*/
+211
View File
@@ -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,
});
};
+634
View File
@@ -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");
}
+6
View File
@@ -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);
}
+263
View File
@@ -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;
}
}
+27
View File
@@ -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;
}
+15
View File
@@ -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";
+287
View File
@@ -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");
}
}
+66
View File
@@ -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;
+82
View File
@@ -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);
}
}
+63
View File
@@ -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);
}
+161
View File
@@ -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*/
+92
View File
@@ -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), {});
}
+2 -2
View File
@@ -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 {
+12 -12
View File
@@ -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);
+8 -6
View File
@@ -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,
+105 -120
View File
@@ -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;
+7 -76
View File
@@ -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>
);
}
+58 -20
View File
@@ -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]
+10 -6
View File
@@ -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
+4 -28
View File
@@ -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]}
+374 -257
View File
@@ -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,
+9 -10
View File
@@ -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() {
+15 -22
View File
@@ -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);
+365 -232
View File
@@ -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;
+12
View File
@@ -0,0 +1,12 @@
/*
Reducer for the annoMatrix
*/
const AnnoMatrix = (state = null, action) => {
if (action.annoMatrix) {
return action.annoMatrix;
}
return state;
};
export default AnnoMatrix;
+7 -13
View File
@@ -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,
};
}
+11 -75
View File
@@ -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 -38
View File
@@ -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
View File
@@ -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;
}
+1 -1
View File
@@ -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 {};
}
+6 -52
View File
@@ -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
-324
View File
@@ -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;
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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,
+8 -12
View File
@@ -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;
+7 -8
View File
@@ -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,
+12
View File
@@ -0,0 +1,12 @@
/*
Reducer for the obsCrossfilter
*/
const ObsCrossfilter = (state = null, action) => {
if (action.obsCrossfilter) {
return action.obsCrossfilter;
}
return state;
};
export default ObsCrossfilter;
-31
View File
@@ -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;
+3 -15
View File
@@ -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",
-308
View File
@@ -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;
-370
View File
@@ -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;
+8 -2
View File
@@ -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
View File
@@ -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);
+14 -3
View File
@@ -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
);
+48 -1
View File
@@ -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]);
}
+48 -7
View File
@@ -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);
+17
View File
@@ -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) {
/*
+85 -89
View File
@@ -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
-12
View File
@@ -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;
-156
View File
@@ -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;
}
-335
View File
@@ -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) =>