diff --git a/package-lock.json b/package-lock.json index 86a5970dc..22604a1af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -306,6 +306,15 @@ "integrity": "sha512-xyWJQMr832vqhu6fD/YqX+MSFBWnkxasNhcStvlhqygXxj0cKqPft0wuGoH5TIq5ADXgP83qeNVa4R7bEYN3uA==", "dev": true }, + "@types/d3-drag": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-1.2.1.tgz", + "integrity": "sha512-J9liJ4NNeV0oN40MzPiqwWjqNi3YHCRtHNfNMZ1d3uL9yh1+vDuo346LBEr8yyBm30WHvrHssAkExVZrGCswtA==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, "@types/d3-format": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.3.0.tgz", @@ -326,6 +335,12 @@ "integrity": "sha512-JqQH5uu1kmdQEa6XSu7NYzQM71lL1YreBPS5o8SnmEDcBRKL6ooykXa8iFPPOEUiTah25ydi+cTrbsogBSMNSQ==", "dev": true }, + "@types/d3-selection": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-1.3.2.tgz", + "integrity": "sha512-K23sDOi7yMussv7aiqk097IWWbjFYbJpcDppQAcaf6DfmHxAsjr+6N4HJGokETLDuV7y/qJeeIJINPnkWJM5Hg==", + "dev": true + }, "@types/d3-time": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.0.9.tgz", @@ -2293,6 +2308,16 @@ "integrity": "sha512-vwKx+lAqB1UuCeklr6Jh1bvC4SZgbSqbkGBLClItFBIYH4vqDJCA7qfoy14lXmJdnBOdxndAMxjCbImJYW7e6g==", "dev": true }, + "d3-drag": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.3.tgz", + "integrity": "sha512-8S3HWCAg+ilzjJsNtWW1Mutl74Nmzhb9yU6igspilaJzeZVFktmY6oO9xOh5TDk+BM2KrNFjttZNoJJmDnkjkg==", + "dev": true, + "requires": { + "d3-dispatch": "1", + "d3-selection": "1" + } + }, "d3-format": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.3.2.tgz", @@ -2332,6 +2357,12 @@ "d3-interpolate": "1" } }, + "d3-selection": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.3.2.tgz", + "integrity": "sha512-OoXdv1nZ7h2aKMVg3kaUFbLLK5jXUFAMLD/Tu5JA96mjf8f2a9ZUESGY+C36t8R1WFeWk/e55hy54Ml2I62CRQ==", + "dev": true + }, "d3-time": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.0.10.tgz", @@ -3726,14 +3757,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3748,20 +3777,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -3878,8 +3904,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -3891,7 +3916,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -3906,7 +3930,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -3914,14 +3937,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -3940,7 +3961,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -4021,8 +4041,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -4034,7 +4053,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -4156,7 +4174,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", diff --git a/package.json b/package.json index 5bd062158..f94ce9e6d 100644 --- a/package.json +++ b/package.json @@ -86,9 +86,11 @@ "devDependencies": { "@types/d3-array": "^1.2.3", "@types/d3-color": "^1.2.1", + "@types/d3-drag": "^1.2.1", "@types/d3-dispatch": "^1.0.6", "@types/d3-scale": "^2.0.2", "@types/d3-scale-chromatic": "^1.3.0", + "@types/d3-selection": "^1.3.2", "@types/d3-time": "^1.0.9", "@types/d3-time-format": "^2.1.0", "@types/detect-browser": "^2.0.1", @@ -97,10 +99,12 @@ "css-loader": "^1.0.0", "d3-array": "^1.2.4", "d3-color": "^1.2.3", + "d3-drag": "^1.2.3", "d3-dispatch": "^1.0.5", "d3-format": "^1.3.2", "d3-scale": "^2.1.2", "d3-scale-chromatic": "^1.3.3", + "d3-selection": "^1.3.2", "d3-time": "^1.0.10", "d3-time-format": "^2.1.3", "detect-browser": "^3.0.1", diff --git a/src/model/OverviewDetailColumn.ts b/src/model/OverviewDetailColumn.ts new file mode 100644 index 000000000..ae2d45709 --- /dev/null +++ b/src/model/OverviewDetailColumn.ts @@ -0,0 +1,99 @@ +import {Category, SupportType, toolbar} from './annotations'; +import {IDataRow, IGroup} from './interfaces'; +import ValueColumn, {IValueColumnDesc} from './ValueColumn'; + +/** + * factory for creating a description creating a rank column + * @param label + * @returns {{type: string, label: string}} + */ +export function createDetailDesc(label: string = 'Detail Checkboxes') { + return {type: 'detail', label, fixed: true}; +} + +export interface IDetailColumnDesc extends IValueColumnDesc { + /** + * setter for selecting/deselecting the given row + */ + setter(row: IDataRow, value: boolean): void; + + /** + * setter for selecting/deselecting the given row + */ + setterAll(rows: IDataRow[], value: boolean): void; +} + +/** + * a checkbox column for selections + */ +@SupportType() +@toolbar('sort', 'sortBy', 'group', 'groupBy', 'selectionToOverviewDetail', 'overviewDetailToSelection') +@Category('support') +export default class OverviewDetailColumn extends ValueColumn { + private static DETAILED_GROUP: IGroup = { + name: 'Detailed', + color: 'blue' + }; + private static NOT_DETAILED_GROUP: IGroup = { + name: 'Undetailed', + color: 'gray' + }; + static readonly EVENT_DETAIL = 'detail'; + + constructor(id: string, desc: Readonly) { + super(id, desc); + this.setDefaultWidth(20); + } + + get frozen() { + return this.desc.frozen !== false; + } + + protected createEventList() { + return super.createEventList().concat([OverviewDetailColumn.EVENT_DETAIL]); + } + + setValue(row: IDataRow, value: boolean) { + const old = this.getValue(row); + if (old === value) { + return true; + } + return this.setImpl(row, value); + } + + setValues(rows: IDataRow[], value: boolean) { + if (rows.length === 0) { + return; + } + if ((this.desc).setterAll) { + (this.desc).setterAll(rows, value); + } + this.fire(OverviewDetailColumn.EVENT_DETAIL, rows[0], value, rows); + return true; + } + + private setImpl(row: IDataRow, value: boolean) { + if ((this.desc).setter) { + (this.desc).setter(row, value); + } + this.fire(OverviewDetailColumn.EVENT_DETAIL, row, value); + return true; + } + + toggleValue(row: IDataRow) { + const old = this.getValue(row); + this.setImpl(row, !old); + return !old; + } + + compare(a: IDataRow, b: IDataRow) { + const va = this.getValue(a) === true; + const vb = this.getValue(b) === true; + return va === vb ? 0 : (va < vb ? -1 : +1); + } + + group(row: IDataRow) { + const isSelected = this.getValue(row); + return isSelected ? OverviewDetailColumn.DETAILED_GROUP : OverviewDetailColumn.NOT_DETAILED_GROUP; + } +} diff --git a/src/model/index.ts b/src/model/index.ts index 4222544b6..e918f2961 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -31,6 +31,7 @@ import StringMapColumn from './StringMapColumn'; import StringsColumn from './StringsColumn'; import ValueColumn, {IValueColumnDesc} from './ValueColumn'; import ImpositionBoxPlotColumn from './ImpositionBoxPlotColumn'; +import OverviewDetailColumn from './OverviewDetailColumn'; import LinkColumn from './LinkColumn'; import LinkMapColumn from './LinkMapColumn'; import LinksColumn from './LinksColumn'; @@ -91,6 +92,7 @@ export {default as Ranking, ISortCriteria} from './Ranking'; export {default as ReduceColumn, createReduceDesc, IReduceDesc, IReduceColumnDesc} from './ReduceColumn'; export {default as ScriptColumn, createScriptDesc, IScriptDesc, IScriptColumnDesc} from './ScriptColumn'; export {default as SelectionColumn, createSelectionDesc, ISelectionColumnDesc} from './SelectionColumn'; +export {default as DetailColumn, createDetailDesc, IDetailColumnDesc} from './OverviewDetailColumn'; export * from './SetColumn'; export {default as SetColumn} from './SetColumn'; export {default as StackColumn, createStackDesc} from './StackColumn'; @@ -150,6 +152,7 @@ export function models() { date: DateColumn, dateMap: DatesMapColumn, dates: DatesColumn, + detail: OverviewDetailColumn, group: GroupColumn, hierarchy: HierarchyColumn, imposition: ImpositionCompositeColumn, diff --git a/src/provider/ACommonDataProvider.ts b/src/provider/ACommonDataProvider.ts index 6c8e87686..82dd4b49c 100644 --- a/src/provider/ACommonDataProvider.ts +++ b/src/provider/ACommonDataProvider.ts @@ -2,6 +2,7 @@ import {createAggregateDesc, createRankDesc, createSelectionDesc, IColumnDesc, I import {IOrderedGroup} from '../model/Group'; import Ranking from '../model/Ranking'; import ADataProvider, {IDataProviderOptions} from './ADataProvider'; +import {createDetailDesc} from '../model/OverviewDetailColumn'; function isComplexAccessor(column: any) { @@ -146,6 +147,7 @@ abstract class ACommonDataProvider extends ADataProvider { if (this.multiSelections) { r.push(this.create(createSelectionDesc())!); } + r.push(this.create(createDetailDesc())!); } this.getColumns().forEach((col) => { const c = this.create(col); diff --git a/src/provider/ADataProvider.ts b/src/provider/ADataProvider.ts index 19dd79388..6463e9104 100644 --- a/src/provider/ADataProvider.ts +++ b/src/provider/ADataProvider.ts @@ -15,6 +15,7 @@ import Ranking, {orderChanged, addColumn, removeColumn} from '../model/Ranking'; import StackColumn from '../model/StackColumn'; import {exportRanking, IExportOptions} from './utils'; import {isSupportType} from '../model/annotations'; +import {createDetailDesc} from '../model/OverviewDetailColumn'; import {IEventListener} from '../internal/AEventDispatcher'; export {IExportOptions} from './utils'; @@ -52,6 +53,16 @@ export interface IDataProvider extends AEventDispatcher { isSelected(i: number): boolean; + detailAllOf(ranking: Ranking): void; + + getDetail(): number[]; + + setDetail(dataIndices: number[]): void; + + toggleDetail(i: number, additional?: boolean): boolean; + + isDetail(i: number): boolean; + removeRanking(ranking: Ranking): void; ensureOneRanking(): void; @@ -135,6 +146,7 @@ export declare function aggregate(ranking: Ranking, group: IGroup|IGroup[], valu */ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { static readonly EVENT_SELECTION_CHANGED = 'selectionChanged'; + static readonly EVENT_DETAIL_CHANGED = 'detailChanged'; static readonly EVENT_ADD_COLUMN = Ranking.EVENT_ADD_COLUMN; static readonly EVENT_REMOVE_COLUMN = Ranking.EVENT_REMOVE_COLUMN; static readonly EVENT_ADD_RANKING = 'addRanking'; @@ -160,6 +172,12 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { */ private readonly selection = new OrderedSet(); + /** + * indices of rows that currently have the detail marking + * @type {OrderedSet} + */ + private readonly detail = new OrderedSet(); + //ranking.id@group.name private aggregations = new Set(); @@ -191,7 +209,7 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { ADataProvider.EVENT_ADD_COLUMN, ADataProvider.EVENT_REMOVE_COLUMN, ADataProvider.EVENT_ADD_RANKING, ADataProvider.EVENT_REMOVE_RANKING, ADataProvider.EVENT_DIRTY, ADataProvider.EVENT_DIRTY_HEADER, ADataProvider.EVENT_DIRTY_VALUES, - ADataProvider.EVENT_ORDER_CHANGED, ADataProvider.EVENT_SELECTION_CHANGED, + ADataProvider.EVENT_ORDER_CHANGED, ADataProvider.EVENT_SELECTION_CHANGED, ADataProvider.EVENT_DETAIL_CHANGED, ADataProvider.EVENT_ADD_DESC, ADataProvider.EVENT_CLEAR_DESC, ADataProvider.EVENT_JUMP_TO_NEAREST, ADataProvider.EVENT_GROUP_AGGREGATION_CHANGED]); } @@ -419,6 +437,10 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { (desc).accessor = (row: IDataRow) => this.isSelected(row.i); (desc).setter = (row: IDataRow, value: boolean) => value ? this.select(row.i) : this.deselect(row.i); (desc).setterAll = (rows: IDataRow[], value: boolean) => value ? this.selectAll(rows.map((d) => d.i)) : this.deselectAll(rows.map((d) => d.i)); + } else if (desc.type === 'detail') { + (desc).accessor = (row: IDataRow) => this.isDetail(row.i); + (desc).setter = (row: IDataRow, value: boolean) => value ? this.addDetail(row.i) : this.removeDetail(row.i); + (desc).setterAll = (rows: IDataRow[], value: boolean) => value ? this.addDetailAll(rows.map((d) => d.i)) : this.removeDetailAll(rows.map((d) => d.i)); } else if (desc.type === 'aggregate') { (desc).isAggregated = (ranking: Ranking, group: IGroup) => this.isAggregated(ranking, group); (desc).setAggregated = (ranking: Ranking, group: IGroup, value: boolean) => this.setAggregated(ranking, group, value); @@ -495,6 +517,7 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { return { uid: this.uid, selection: this.getSelection(), + detail: this.getDetail(), aggregations: Array.from(this.aggregations), rankings: this.rankings.map((r) => r.dump(this.toDescRef)) }; @@ -556,6 +579,10 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { if (dump.selection) { dump.selection.forEach((s: number) => this.selection.add(s)); } + //restore detail + if (dump.detail) { + dump.detail.forEach((s: number) => this.detail.add(s)); + } if (dump.aggregations) { this.aggregations.clear(); dump.aggregations.forEach((a: string) => this.aggregations.add(a)); @@ -603,6 +630,8 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { return this.create(createRankDesc()); case 'selection': return this.create(createSelectionDesc()); + case 'detail': + return this.create(createDetailDesc()); case 'group': return this.create(createGroupDesc()); case 'aggregate': @@ -743,6 +772,15 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { return this.selection.has(index); } + /** + * has the given row the detail marking + * @param index + * @returns {boolean} + */ + isDetail(index: number) { + return this.detail.has(index); + } + /** * also select the given row * @param index @@ -758,6 +796,18 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { this.fire(ADataProvider.EVENT_SELECTION_CHANGED, this.getSelection()); } + /** + * also set the detail marking for the given row + * @param {number} index + */ + addDetail(index: number) { + if (this.detail.has(index)) { + return; //no change + } + this.detail.add(index); + this.fire(ADataProvider.EVENT_DETAIL_CHANGED, this.getDetail()); + } + /** * hook for selecting elements matching the given arguments * @param search @@ -790,10 +840,28 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { this.fire(ADataProvider.EVENT_SELECTION_CHANGED, this.getSelection()); } + /** + * also set the detail marking for the given rows + * @param indices + */ + addDetailAll(indices: number[]) { + if (indices.every((i) => this.detail.has(i))) { + return; //no change + } + indices.forEach((index) => { + this.detail.add(index); + }); + this.fire(ADataProvider.EVENT_DETAIL_CHANGED, this.getDetail()); + } + selectAllOf(ranking: Ranking) { this.setSelection(ranking.getOrder()); } + detailAllOf(ranking: Ranking) { + this.setDetail(ranking.getOrder()); + } + /** * set the selection to the given rows * @param indices @@ -809,6 +877,21 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { this.selectAll(indices); } + /** + * set the detail marking to the given rows + * @param indices + */ + setDetail(indices: number[]) { + if (indices.length === 0) { + return this.clearDetail(); + } + if (this.detail.size === indices.length && indices.every((i) => this.detail.has(i))) { + return; //no change + } + this.detail.clear(); + this.addDetailAll(indices); + } + /** * toggles the selection of the given data index * @param index @@ -832,6 +915,29 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { return true; } + /** + * toggles the detail marking of the given data index + * @param index + * @param additional just this element or all + * @returns {boolean} whether the index currently has the detail marking + */ + toggleDetail(index: number, additional = false) { + if (this.isDetail(index)) { + if (additional) { + this.removeDetail(index); + } else { + this.clearDetail(); + } + return false; + } + if (additional) { + this.addDetail(index); + } else { + this.setDetail([index]); + } + return true; + } + /** * deselect the given row * @param index @@ -845,7 +951,19 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { } /** - * also select all the given rows + * remove detail marking for given row + * @param index + */ + removeDetail(index: number) { + if (!this.detail.has(index)) { + return; //no change + } + this.detail.delete(index); + this.fire(ADataProvider.EVENT_DETAIL_CHANGED, this.getDetail()); + } + + /** + * remove detail markings for given rows * @param indices */ deselectAll(indices: number[]) { @@ -858,6 +976,20 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { this.fire(ADataProvider.EVENT_SELECTION_CHANGED, this.getSelection()); } + /** + * + * @param {number[]} indices + */ + removeDetailAll(indices: number[]) { + if (indices.every((i) => !this.detail.has(i))) { + return; //no change + } + indices.forEach((index) => { + this.detail.delete(index); + }); + this.fire(ADataProvider.EVENT_DETAIL_CHANGED, this.getDetail()); + } + /** * returns a promise containing the selected rows * @return {Promise} @@ -869,6 +1001,17 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { return this.view(this.getSelection()); } + /** + * returns a promise containing the rows with the detail marking + * @returns {Promise} + */ + detailRows(): Promise | any[] { + if (this.detail.size === 0) { + return []; + } + return this.view(this.getDetail()); + } + /** * returns the currently selected indices * @returns {Array} @@ -877,6 +1020,14 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { return Array.from(this.selection); } + /** + * returns indices of detail markings + * @returns {Array} + */ + getDetail() { + return Array.from(this.detail); + } + /** * clears the selection */ @@ -888,6 +1039,17 @@ abstract class ADataProvider extends AEventDispatcher implements IDataProvider { this.fire(ADataProvider.EVENT_SELECTION_CHANGED, [], false); } + /** + * clears all detail markings + */ + clearDetail() { + if (this.detail.size === 0) { + return; //no change + } + this.detail.clear(); + this.fire(ADataProvider.EVENT_DETAIL_CHANGED, [], false); + } + /** * utility to export a ranking to a table with the given separator * @param ranking diff --git a/src/renderer/OverviewDetailRenderer.ts b/src/renderer/OverviewDetailRenderer.ts new file mode 100644 index 000000000..8ee544fe6 --- /dev/null +++ b/src/renderer/OverviewDetailRenderer.ts @@ -0,0 +1,113 @@ +import {IDataRow, IGroup} from '../model'; +import Column from '../model/Column'; +import {default as IRenderContext, ICellRendererFactory} from './interfaces'; +import {noop} from './utils'; +import {IDataProvider} from '../provider/ADataProvider'; +import OverviewDetailColumn from '../model/OverviewDetailColumn'; + +/** @internal */ +export default class OverviewDetailRenderer implements ICellRendererFactory { + readonly title = 'Default'; + + canRender(col: Column) { + return col instanceof OverviewDetailColumn; + } + + create(col: OverviewDetailColumn, ctx: IRenderContext) { + return { + template: `
`, + update: (n: HTMLElement, d: IDataRow, i: number) => { + n.onclick = function (event) { + event.preventDefault(); + event.stopPropagation(); + if (event.shiftKey) { + const ranking = col.findMyRanker()!.id; + if (rangeSelection(ctx.provider, ranking, d.i, i, event.ctrlKey)) { + return; + } + } + + col.toggleValue(d); + }; + }, + render: noop + }; + } + + createGroup(col: OverviewDetailColumn) { + return { + template: `
`, + update: (n: HTMLElement, _group: IGroup, rows: IDataRow[]) => { + const selected = rows.reduce((act, r) => col.getValue(r) ? act + 1 : act, 0); + const all = selected >= rows.length / 2; + if (all) { + n.classList.add('lu-group-selected'); + } else { + n.classList.remove('lu-group-selected'); + } + n.onclick = function (event) { + event.preventDefault(); + event.stopPropagation(); + const value = n.classList.toggle('lu-group-selected'); + col.setValues(rows, value); + }; + } + }; + } + + createSummary(col: OverviewDetailColumn, context: IRenderContext) { + return { + template: `
`, + update: (node: HTMLElement) => { + node.onclick = (evt) => { + evt.stopPropagation(); + const icon = node.dataset.icon; + if (icon === 'unchecked') { + context.provider.detailAllOf(col.findMyRanker()!); + node.dataset.icon = 'checked'; + } else { + context.provider.setDetail([]); + node.dataset.icon = 'unchecked'; + } + }; + } + }; + } +} + +/** @internal */ +export function rangeSelection(provider: IDataProvider, rankingId: string, dataIndex: number, relIndex: number, ctrlKey: boolean) { + const ranking = provider.getRankings().find((d) => d.id === rankingId); + if (!ranking) { // no known reference + return false; + } + const selection = provider.getDetail(); + if (selection.length === 0 || selection.includes(dataIndex)) { + return false; // no other or deselect + } + const order = ranking.getOrder(); + const lookup = new Map(ranking.getOrder().map((d, i) => <[number, number]>[d, i])); + const distances = selection.map((d) => { + const index = (lookup.has(d) ? lookup.get(d)! : Infinity); + return {s: d, index, distance: Math.abs(relIndex - index)}; + }); + const nearest = distances.sort((a, b) => a.distance - b.distance)[0]!; + if (!isFinite(nearest.distance)) { + return false; // all outside + } + if (!ctrlKey) { + selection.splice(0, selection.length); + selection.push(nearest.s); + } + if (nearest.index < relIndex) { + for(let i = nearest.index + 1; i <= relIndex; ++i) { + selection.push(order[i]); + } + } else { + for(let i = relIndex; i <= nearest.index; ++i) { + selection.push(order[i]); + } + } + provider.setDetail(selection); + return true; +} diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 524e3975e..835b9adfa 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -30,6 +30,7 @@ import StringCellRenderer from './StringCellRenderer'; import TableCellRenderer from './TableCellRenderer'; import UpSetCellRenderer from './UpSetCellRenderer'; import VerticalBarCellRenderer from './VerticalBarCellRenderer'; +import OverviewDetailRenderer from './OverviewDetailRenderer'; export { default as IRenderContext, @@ -60,6 +61,7 @@ export const renderers: { [key: string]: ICellRendererFactory } = { categorical: new CategoricalCellRenderer(), circle: new CircleCellRenderer(), default: defaultCellRenderer, + detail: new OverviewDetailRenderer(), dot: new DotCellRenderer(), group: new GroupCellRenderer(), heatmap: new HeatmapCellRenderer(), diff --git a/src/styles/engine/_header.scss b/src/styles/engine/_header.scss index 34f8fa4c8..3e56f3e32 100644 --- a/src/styles/engine/_header.scss +++ b/src/styles/engine/_header.scss @@ -38,20 +38,20 @@ margin-right: $lu-engine_grip_gap; font-weight: 500; - &.has-marker { - margin-right: 18px; - } + &.has-marker { + margin-right: 18px; + } - > i { - position: absolute; - right: 6px; - cursor: default; - color: $lu_toolbar_color_base; + > i { + position: absolute; + right: 6px; + cursor: default; + color: $lu_toolbar_color_base; - &:hover { - color: $lu_toolbar_color_hover; - } + &:hover { + color: $lu_toolbar_color_hover; } + } } .#{$lu_css_prefix}-th-summary { @@ -62,7 +62,8 @@ } &[data-renderer=aggregate], - &[data-renderer=selection] { + &[data-renderer=selection], + &[data-renderer=detail] { display: block; // have just a before element } } diff --git a/src/styles/engine/_index.scss b/src/styles/engine/_index.scss index 8c6d96955..16684a71a 100644 --- a/src/styles/engine/_index.scss +++ b/src/styles/engine/_index.scss @@ -14,3 +14,4 @@ @import './header'; @import './selection'; @import './row'; +@import './texture_renderer'; diff --git a/src/styles/engine/_texture_renderer.scss b/src/styles/engine/_texture_renderer.scss new file mode 100644 index 000000000..235c5b9dd --- /dev/null +++ b/src/styles/engine/_texture_renderer.scss @@ -0,0 +1,76 @@ +@import '../vars'; + +##{$lu-css_prefix}-texture-container { + display: flex; + flex: 1 1 auto; + flex-direction: row; + overflow-y: auto; + overflow-x: auto; + + .rankingContainer { + display: flex; + flex-direction: column; + + .rowContainer { + position: relative; + + .textureContainer { + display: flex; + + .columnContainer { + flex: 0 0 auto; + margin-right: 5px; + display: flex; + flex-direction: column; + justify-content: space-between; + + &.partOfComposite { + margin-right: 6px; + } + + canvas { + width: 100%; + height: 100%; + } + } + } + + .engineRendererContainer { + > header { + display: none; + } + + > main { + overflow: hidden; + width: 100%; + } + } + } + } + + &:not(.expand) { + .engineRendererContainer:not(.always) { + display: none; + } + } + + .rowContainer.expanded { + .engineRendererContainer:not(.always) { + display: block; + } + } + + &.expand, + .rowContainer.expanded { + .textureContainer:not(.always) { + display: none; + } + } + + #lu-drag-overlay { + position: absolute; + background-color: #c1c1c1; + opacity: 0.4; + left: 0; + } +} diff --git a/src/styles/header/_index.scss b/src/styles/header/_index.scss index c4b14103c..02d833bad 100644 --- a/src/styles/header/_index.scss +++ b/src/styles/header/_index.scss @@ -10,17 +10,17 @@ justify-content: flex-start; background: white; - - > i { - padding-top: 2px; - margin-left: 6px; - cursor: default; - color: $lu_toolbar_color_base; - - &:hover { - color: $lu_toolbar_color_hover; - } + > i { + padding-top: 2px; + margin-left: 6px; + cursor: default; + color: $lu_toolbar_color_base; + + &:hover { + color: $lu_toolbar_color_hover; } + } + &.#{$lu_css_prefix}-dragging { opacity: 0.5; } diff --git a/src/styles/renderer/_detail.scss b/src/styles/renderer/_detail.scss new file mode 100644 index 000000000..afcf808e5 --- /dev/null +++ b/src/styles/renderer/_detail.scss @@ -0,0 +1,25 @@ +@import '../vars'; +@import '../icons/index'; + +.#{$lu_css_prefix}-row [data-renderer=detail] { + text-align: center; + + &::before { + @include lu_icons(); + + display: inline; + content: $lu_icon_square_o; + } +} + +.#{$lu_css_prefix}-selected [data-renderer=detail]::before, +.selected [data-renderer=detail]::before, +.#{$lu_css_prefix}-group-selected[data-renderer=detail]::before { + content: $lu_icon_check_square_o; + padding-left: 2px; +} + +.#{$lu_css_prefix}-summary[data-renderer=detail] { + cursor: pointer; + font-size: medium; +} diff --git a/src/styles/renderer/_index.scss b/src/styles/renderer/_index.scss index c5ae7ad6b..da6150aaf 100644 --- a/src/styles/renderer/_index.scss +++ b/src/styles/renderer/_index.scss @@ -1,20 +1,22 @@ - -@import './aggregate'; -@import './bar'; -@import './boxplot'; -@import './catdistributionbar'; -@import './categorical'; -@import './dot'; -@import './heatmap'; -@import './histogram'; -@import './image'; -@import './interleaving'; -@import './rank'; -@import './selection'; -@import './sparkline'; -@import './stack'; -@import './string'; -@import './table'; -@import './threshold'; -@import './upset'; -@import './verticalbar'; +.#{$lu_css_prefix} { + @import './aggregate'; + @import './bar'; + @import './boxplot'; + @import './catdistributionbar'; + @import './categorical'; + @import './detail'; + @import './dot'; + @import './heatmap'; + @import './histogram'; + @import './image'; + @import './interleaving'; + @import './rank'; + @import './selection'; + @import './sparkline'; + @import './stack'; + @import './string'; + @import './table'; + @import './threshold'; + @import './upset'; + @import './verticalbar'; +} diff --git a/src/ui/CanvasTextureRenderer.ts b/src/ui/CanvasTextureRenderer.ts new file mode 100644 index 000000000..37f95551a --- /dev/null +++ b/src/ui/CanvasTextureRenderer.ts @@ -0,0 +1,558 @@ +import EngineRanking from './EngineRanking'; +import {IDataRow} from '../model'; +import {scaleLinear, scaleOrdinal} from 'd3-scale'; +import Column from '../model/Column'; +import NumberColumn from '../model/NumberColumn'; +import NumbersColumn from '../model/NumbersColumn'; +import CategoricalColumn from '../model/CategoricalColumn'; +import CategoricalsColumn from '../model/CategoricalsColumn'; +import CompositeColumn from '../model/CompositeColumn'; +import {select as d3Select, mouse as d3Mouse, event as d3Event} from 'd3-selection'; +import {drag as d3Drag} from 'd3-drag'; +import {ILineUpOptions} from '../interfaces'; +import EngineRenderer from './EngineRenderer'; +import { MultiTableRowRenderer, GridStyleManager } from 'lineupengine'; +import SelectionColumn from '../model/SelectionColumn'; +import OverviewDetailColumn from '../model/OverviewDetailColumn'; +import Ranking from '../model/Ranking'; + +export interface ITextureRenderer { + update(rankings?: EngineRanking[], localData?: IDataRow[][]): void; + expandTextureRenderer(use: boolean): void; + destroy(): void; + show(): void; + hide(): void; + updateSelection(dataIndices: number[]): void; + addRanking(ranking: EngineRanking): void; + removeRanking(ranking: Ranking | null): void; + convertSelectionToDetail(): void; + convertDetailToSelection(): void; +} + +export default class CanvasTextureRenderer implements ITextureRenderer { + static readonly MAX_CANVAS_SIZE = 32767; + static readonly AGGREGATED_ROW_HEIGHT = 45; + static readonly RENDER_ROW_PADDING = 10; + static readonly EXPANDED_ROW_CLASS = 'expand'; + static readonly SELECTION_DRAW_WIDTH = 2; + static readonly SELECTION_DRAW_COLOR = '#ffa809'; + static readonly SELECTION_DRAW_BACKGROUN_COLOR = '#ffffff'; + static readonly SCROLLBAR_HEIGHT = 20; + + readonly node: HTMLElement; + readonly canvas: any; + readonly headerNode: HTMLElement; + private renderedColumns: any[]; + private dragStartPosition: [number, number] = [0,0]; + private dragOverlay: HTMLElement; + private detailParts: any[]; + private currentRankings: EngineRanking[] = []; + private currentLocalData: IDataRow[][] = []; + private currentNodeHeight: number = 0; + private currentRankingWidths: number[] = []; + private engineRenderer: EngineRenderer; + private engineRankings: EngineRanking[][] = []; + private alreadyExpanded: boolean = false; + private expandLaterRows: any[] = []; + private readonly options: Readonly; + + constructor(parent: Element, engineRenderer: EngineRenderer, options: Readonly) { + this.node = parent.ownerDocument!.createElement('main'); + this.node.id = 'lu-texture-container'; + parent.appendChild(this.node); + this.canvas = parent.ownerDocument!.createElement('canvas'); + this.headerNode = d3Select(parent).select('header').node(); + this.engineRenderer = engineRenderer; + this.options = options; + this.renderedColumns = []; + this.detailParts = []; + this.dragOverlay = this.node; + + this.node.addEventListener('scroll', () => { + { + //scroll header with main panel + this.headerNode.scrollLeft = this.node.scrollLeft; + } + }); + } + + updateSelection(dataIndices: number[]) { + const s = new Set(dataIndices); + this.engineRankings.forEach((v) => v.forEach((r) => r.updateSelection(s))); + this.drawSelection(); + this.update(); + } + + update(rankings: EngineRanking[] = this.currentRankings, localData: IDataRow[][] = this.currentLocalData) { + this.detailParts = []; + rankings.forEach((r, i) => { + const rankingIndex = this.currentRankings.findIndex((v) => v === r); + this.currentLocalData[rankingIndex] = localData[i]; + }); + this.currentNodeHeight = this.node.offsetHeight; + let totalWidth = 0; + rankings.forEach((r, i) => { + let rankingWidth = 0; + r.ranking.flatColumns.forEach((c) => 'children' in c ? rankingWidth += (c).children.length : rankingWidth += c.getWidth() + 5); + this.currentRankingWidths[i] = rankingWidth; + totalWidth += rankingWidth; + }); + if (totalWidth > this.node.clientWidth) { + this.currentNodeHeight -= CanvasTextureRenderer.SCROLLBAR_HEIGHT; + } + this.alreadyExpanded = this.node.classList.contains(CanvasTextureRenderer.EXPANDED_ROW_CLASS); + + this.renderColumns(rankings, localData); + } + + private renderColumns (rankings: EngineRanking[], localData: IDataRow[][]) { + rankings.forEach((r, i) => { + //first find the aggregated parts + let notAggregatedCount = localData[i].length; + let gIndex = 0; + const aggregatedParts = []; + r.ranking.getGroups().forEach((g) => { + if (this.engineRenderer.ctx.provider.isAggregated(r.ranking, g)) { + aggregatedParts.push([gIndex, gIndex + g.order.length - 1]); + notAggregatedCount -= g.order.length; + } + gIndex += g.order.length; + }); + this.currentNodeHeight -= CanvasTextureRenderer.AGGREGATED_ROW_HEIGHT * aggregatedParts.length; + + //then find the parts to show details + const rankingIndex = this.currentRankings.findIndex((v) => v === r); + this.engineRankings[rankingIndex] = []; + this.detailParts = []; + let startIndex = -1; + for (let j = 0; j < localData[i].length; j++) { + if (this.engineRenderer.ctx.provider.isDetail(localData[i][j].i)) { + if (startIndex === -1) { + startIndex = j; + } + } else if (startIndex !== -1) { + this.detailParts.push([startIndex, j-1]); + startIndex = -1; + } + } + if (startIndex !== -1) { + this.detailParts.push([startIndex, localData[i].length-1]); + } + + //combine aggregated parts with detail parts + const aggregateIndices = []; + aggregatedParts.forEach((g: any) => { + for (let j = 0; j < this.detailParts.length; j++) { + if (g[0] <= this.detailParts[j][0]) { + if (g[1] < this.detailParts[j][0]) { + this.detailParts.splice(j, 0, g); + aggregateIndices.push(j); + return; + } + if (g[1] < this.detailParts[j][1]) { + this.detailParts.splice(j, 1, g, [g[1] + 1, this.detailParts[j][1]]); + aggregateIndices.push(j); + return; + } + this.detailParts.splice(j, 1); + j--; + continue; + } + if (g[0] > this.detailParts[j][1]) { + continue; + } + if (g[1] < this.detailParts[j][1]) { + this.detailParts.splice(j, 1, [this.detailParts[j][0], g[0] - 1], g, [g[1] + 1, this.detailParts[j][1]]); + aggregateIndices.push(j + 1); + return; + } + this.detailParts.splice(j, 1, [this.detailParts[j][0], g[0] - 1]); + } + this.detailParts.push(g); + aggregateIndices.push(this.detailParts.length - 1); + }); + + const dataParts = []; + const expandableParts = []; + const aggregateParts = []; + if(this.detailParts.length === 0) { + dataParts.push(localData[i].length); + } else { + let next = 0; + this.detailParts.forEach((v, j) => { + const curFrom = v[0]; + const curTo = v[1]; + if (curFrom > next) { + dataParts.push(curFrom); + } + expandableParts.push(dataParts.length); + if (aggregateIndices.includes(j)) { + aggregateParts.push(dataParts.length); + } + dataParts.push(curTo + 1); + next = curTo + 1; + }); + if (next < localData[i].length) { + dataParts.push(localData[i].length); + } + } + let curIndex = 0; + const rankingDiv = d3Select(this.node).select(`[data-ranking="${rankingIndex}"]`)!.node(); + if (!rankingDiv) { + return; + } + rankingDiv.innerHTML = ''; //remove all children + const grouped = r.groupData(localData[i]); + let aggregateOffset = 0; + dataParts.forEach((v: number, di: number) => { + const expandable = expandableParts.includes(di); + const aggregated = aggregateParts.includes(di); + let newOffset = 0; + if (aggregated) { + newOffset = v - curIndex - 1; + } + const data = grouped.slice(curIndex - aggregateOffset, v - aggregateOffset - newOffset); + + const rowDiv = this.node.ownerDocument!.createElement('div'); + rowDiv.setAttribute('data-from', `${curIndex}`); + rowDiv.setAttribute('data-to', `${v-1}`); + rowDiv.classList.add('rowContainer'); + rankingDiv.appendChild(rowDiv); + + curIndex = v; + aggregateOffset += newOffset; + + if (!aggregated) { + const height = data.length / notAggregatedCount * this.currentNodeHeight; + if (height >= 1) { //only render parts larger than 1px + const textureDiv = this.node.ownerDocument!.createElement('div'); + textureDiv.style.height = `${height}px`; + textureDiv.classList.add('textureContainer'); + if (!expandable) { + textureDiv.classList.add('always'); + } + this.renderedColumns = []; + r.ranking.flatColumns.forEach((column) => this.createColumn(column, data, textureDiv, false, expandable)); + rowDiv.appendChild(textureDiv); + } + } + if (expandable) { + const expandLater = () => { + const engineRendererDiv = this.node.ownerDocument!.createElement('article'); + engineRendererDiv.classList.add('engineRendererContainer'); + if (aggregated) { + engineRendererDiv.classList.add('always'); + } + if (aggregated) { + engineRendererDiv.style.height = `${CanvasTextureRenderer.AGGREGATED_ROW_HEIGHT}px`; + } else { + engineRendererDiv.style.height = `${(this.options.rowHeight + this.options.rowPadding) * data.length + CanvasTextureRenderer.RENDER_ROW_PADDING}px`; + } + engineRendererDiv.style.width = `${this.currentRankingWidths[i]}px`; + + rowDiv.appendChild(engineRendererDiv); + + const table = new MultiTableRowRenderer(engineRendererDiv, `#${this.engineRenderer.idPrefix}`); + const engineRanking = table.pushTable((header: HTMLElement, body: HTMLElement, tableId: string, style: GridStyleManager) => new EngineRanking(r.ranking, header, body, tableId, style, this.engineRenderer.ctx, { + animation: this.options.animated, + customRowUpdate: this.options.customRowUpdate || (() => undefined), + levelOfDetail: this.options.levelOfDetail || (() => 'high'),// + flags: this.options.flags + }, false)); + + this.engineRenderer.render(engineRanking, data); + this.engineRankings[rankingIndex].push(engineRanking); + }; + if (this.alreadyExpanded || aggregated) { + expandLater(); + } else { + this.expandLaterRows.push(expandLater); + } + } + + if (expandable) { + return; + } + const that = this; + d3Select(rowDiv) + .call((d3Drag()) + .on('start', (_: any, __: any, element: HTMLElement[]) => { that.dragStarted(element[0]); }) + .on('drag', (_: any, __: any, element: HTMLElement[]) => { that.dragged(element[0]); }) + .on('end', (_: any, __: any, element: HTMLElement[]) => { that.dragEnd(element[0]); })); + }); + }); + this.drawSelection(); + } + + private createColumn(column: Column, grouped: any[], container: HTMLElement, partOfComposite: boolean, expandable: boolean) { + if (this.renderedColumns.includes(column.id)) { + if (partOfComposite) { + const $container = d3Select(container); + const $col = $container.select(`.columnContainer[data-columnid="${column.id}"]`).node(); + if ($col !== null) { + $container.append(() => $col); //reorder the column + return; + } + } else { + return; //column already rendered + } + } + + const columnContainer = this.node.ownerDocument!.createElement('div'); + columnContainer.style.width = `${column.getWidth()}px`; + columnContainer.setAttribute('data-columnid', column.id); + columnContainer.classList.add('columnContainer'); + if (partOfComposite) { + columnContainer.classList.add('partOfComposite'); + } + + let newElements = []; + if (column instanceof NumbersColumn) { + const col = column; + newElements = this.generateImage(grouped.map((value) => { + return (value).v[(col.desc).column]; + }), CanvasTextureRenderer.getColorScale(col)); + } else if (column instanceof NumberColumn) { + const col = column; + newElements = this.generateImage(grouped.map((value) => { + return [(value).v[(col.desc).column]]; + }), CanvasTextureRenderer.getColorScale(col)); + } else if (column instanceof CategoricalsColumn) { + const col = column; + newElements = this.generateImage(grouped.map((value) => { + return (value).v[(col.desc).column]; + }), CanvasTextureRenderer.getColorScale(col)); + } else if (column instanceof CategoricalColumn) { + const col = column; + newElements = this.generateImage(grouped.map((value) => { + return [(value).v[(col.desc).column]]; + }), CanvasTextureRenderer.getColorScale(col)); + } else if (column instanceof SelectionColumn) { + const col = column; + newElements = this.generateImage(grouped.map((value) => { + return [this.engineRenderer.ctx.provider.isSelected((value).i)]; + }), CanvasTextureRenderer.getColorScale(col)); + } else if (column instanceof OverviewDetailColumn) { + const col = column; + newElements = this.generateImage(grouped.map((value) => { + return [this.engineRenderer.ctx.provider.isDetail((value).i)]; + }), CanvasTextureRenderer.getColorScale(col)); + } else if ('children' in column) { + //handle composite columns + (column).children.forEach((c) => this.createColumn(c, grouped, container, true, expandable)); + return; + } else { + newElements.push(this.node.ownerDocument!.createElement('canvas')); + } + + newElements.forEach((newElement: any) => columnContainer.appendChild(newElement)); + + container.appendChild(columnContainer); + this.renderedColumns.push(column.id); + } + + private static getColorScale(column: Column) { + let domain = [0, 0]; + + if (column instanceof NumberColumn || column instanceof NumbersColumn) { + const colorScale = scaleLinear(); + domain = column.getMapping().domain; + if (domain[0] < 0 && domain[1] > 0) { // diverging + colorScale + .domain([domain[0], 0, domain[1]]); + } else { + colorScale + .domain([domain[0], domain[1]]); + } + colorScale.range(['white', column.color ? column.color : 'black']); + return colorScale; + } + if (column instanceof CategoricalColumn) { + const colorScale = scaleOrdinal(); + const categories = column.categories; + colorScale + .domain(categories.map((v) => v.name)) + .range(categories.map((v) => v.color)); + return colorScale; + } + + if (column instanceof SelectionColumn) { + const colorScale = scaleOrdinal(); + colorScale + .domain([false, true]) + .range(['transparent', 'orange']); + return colorScale; + } + + if (column instanceof OverviewDetailColumn) { + const colorScale = scaleOrdinal(); + colorScale + .domain([false, true]) + .range(['transparent', 'blue']); + return colorScale; + } + return null; + } + + private generateImage(data: any[][], colorScale: any) { + const totalLength = data.length; + const newElements = []; + while (data.length > 0) { + const chunk = data.splice(0, CanvasTextureRenderer.MAX_CANVAS_SIZE); + const height = chunk.length; + let width = 0; + if(height > 0) { + width = chunk[0].length; + } + const canvas = this.node.ownerDocument!.createElement('canvas'); + canvas.setAttribute('height', `${height}`); + canvas.setAttribute('width', `${width}`); + canvas.style.flexGrow = `${height}`; + canvas.style.height = `${chunk.length / totalLength * 100}%`; + this.drawOntoCanvas(chunk, colorScale, canvas); + newElements.push(canvas); + } + return newElements; + } + + private drawOntoCanvas(data: any[][], colorScale: any, canvas: any) { + if (colorScale === null) { + return; + } + const ctx = canvas.getContext('2d'); + data.forEach((row, y) => { + row.forEach((value, x) => { + ctx.fillStyle = colorScale(value); + ctx.fillRect(x, y, 1, 1); + }); + }); + ctx.save(); + } + + expandTextureRenderer(use: boolean) { + d3Select(this.node).classed(CanvasTextureRenderer.EXPANDED_ROW_CLASS, use); + if (!this.alreadyExpanded) { + this.expandLaterRows.forEach((r) => r()); + this.alreadyExpanded = true; + } + } + + private dragStarted(element: any) { + this.dragStartPosition = d3Mouse(element); + this.dragOverlay = element.ownerDocument.createElement('div'); + this.dragOverlay.id = 'lu-drag-overlay'; + this.dragOverlay.style.width = `${element.scrollWidth}px`; + element.appendChild(this.dragOverlay); + } + + private dragged(element: any) { + const currentPosition = d3Mouse(element); + if (this.dragStartPosition[1] < currentPosition[1]) { + this.dragOverlay.style.top = `${this.dragStartPosition[1]}px`; + this.dragOverlay.style.height = `${currentPosition[1]-this.dragStartPosition[1]}px`; + } else { + this.dragOverlay.style.top = `${currentPosition[1]}px`; + this.dragOverlay.style.height = `${this.dragStartPosition[1]-currentPosition[1]}px`; + } + } + + private dragEnd(element: any) { + this.dragOverlay.remove(); + const currentPosition = d3Mouse(element); + if(currentPosition[1] === this.dragStartPosition[1]) { + if (!d3Event.sourceEvent.ctrlKey) { + if (d3Event.sourceEvent.altKey) { + this.engineRenderer.ctx.provider.setDetail([]); + } else { + this.engineRenderer.ctx.provider.setSelection([]); + } + } + return; + } + const from = Math.min(currentPosition[1], this.dragStartPosition[1]); + const to = Math.max(currentPosition[1], this.dragStartPosition[1]); + const fromData = parseInt(element.getAttribute('data-from'), 10); + const toData = parseInt(element.getAttribute('data-to'), 10); + const fromIndex = Math.max(Math.floor(from / element.offsetHeight * (toData - fromData) + fromData), fromData); + const toIndex = Math.min(Math.ceil(to / element.offsetHeight * (toData - fromData) + fromData), toData); + if (fromIndex > toIndex) { + return; + } + const ranking = element.parentElement.getAttribute('data-ranking'); + const indices : number[] = d3Event.sourceEvent.ctrlKey ? (d3Event.sourceEvent.altKey ? this.engineRenderer.ctx.provider.getDetail() : this.engineRenderer.ctx.provider.getSelection()) :[]; + this.currentLocalData[ranking].slice(fromIndex, toIndex).forEach((d) => { + indices.push(d.i); + }); + if (d3Event.sourceEvent.altKey) { + this.engineRenderer.ctx.provider.setDetail(indices); + } else { + this.engineRenderer.ctx.provider.setSelection(indices); + } + } + + addRanking(ranking: EngineRanking) { + this.currentRankings.push(ranking); + this.currentLocalData.push([]); + const rankingDiv = this.node.ownerDocument!.createElement('div'); + rankingDiv.classList.add('rankingContainer'); + rankingDiv.setAttribute('data-ranking', `${this.currentRankings.length-1}`); + this.node.appendChild(rankingDiv); + } + + removeRanking(ranking: Ranking | null) { + if (!ranking) { + this.node.innerHTML = ''; + } + const index = this.currentRankings.findIndex((r) => r.ranking === ranking); + if (index < 0) { + return; // error + } + this.currentRankings.splice(index, 1); + this.engineRankings.splice(index, 1); + this.currentLocalData.splice(index, 1); + d3Select(this.node).select(`[data-ranking="${index}"]`).remove(); + } + + destroy() { + this.node.remove(); + } + + show() { + this.node.style.display = null; + } + + hide() { + this.node.style.display = 'none'; + } + + convertSelectionToDetail() { + this.engineRenderer.ctx.provider.setDetail(this.engineRenderer.ctx.provider.getSelection()); + } + + convertDetailToSelection() { + this.engineRenderer.ctx.provider.setSelection(this.engineRenderer.ctx.provider.getDetail()); + } + + drawSelection() { + d3Select(this.node).selectAll('.selectionColumn').nodes().forEach((v: any) => { + let parent = v.parentElement; + while (!parent.classList.contains('rowContainer')) { + parent = parent.parentElement; + } + const fromIndex = parseInt(parent.getAttribute('data-from'), 10); + const toIndex = parseInt(parent.getAttribute('data-to'), 10); + + const ctx = v.getContext('2d'); + + for (let i = fromIndex; i <= toIndex; i++) { + if (this.engineRenderer.ctx.provider.isSelected(this.currentLocalData[0][i].i)) { + ctx.fillStyle = CanvasTextureRenderer.SELECTION_DRAW_COLOR; + } else { + ctx.fillStyle = CanvasTextureRenderer.SELECTION_DRAW_BACKGROUN_COLOR; + } + ctx.fillRect(CanvasTextureRenderer.SELECTION_DRAW_WIDTH, i, CanvasTextureRenderer.SELECTION_DRAW_WIDTH, 1); + } + ctx.save(); + }); + } +} diff --git a/src/ui/EngineRanking.ts b/src/ui/EngineRanking.ts index e669179d2..ed826230d 100644 --- a/src/ui/EngineRanking.ts +++ b/src/ui/EngineRanking.ts @@ -163,7 +163,7 @@ export default class EngineRanking extends ACellTableSection imple } }; - constructor(public readonly ranking: Ranking, header: HTMLElement, body: HTMLElement, tableId: string, style: GridStyleManager, private readonly ctx: IEngineRankingContext, roptions: Partial = {}) { + constructor(public readonly ranking: Ranking, header: HTMLElement, body: HTMLElement, tableId: string, style: GridStyleManager, private readonly ctx: IEngineRankingContext, roptions: Partial = {}, readonly addEventListener: boolean = true) { super(header, body, tableId, style, {mixins: [PrefetchMixin], batchSize: 20}); Object.assign(this.roptions, roptions); body.dataset.ranking = ranking.id; @@ -185,43 +185,47 @@ export default class EngineRanking extends ACellTableSection imple this.delayedUpdateAll = debounce(() => this.updateAll(), 50); this.delayedUpdateColumnWidths = debounce(() => this.updateColumnWidths(), 50); - ranking.on(`${Ranking.EVENT_ADD_COLUMN}.hist`, (col: Column, index: number) => { - this.columns.splice(index, 0, this.createCol(col, index)); - this.reindex(); - this.updateHist(col); - this.delayedUpdateAll(); - }); - ranking.on(`${Ranking.EVENT_REMOVE_COLUMN}.body`, (col: Column, index: number) => { - EngineRanking.disableListener(col); - this.columns.splice(index, 1); - this.reindex(); - this.delayedUpdateAll(); - }); - ranking.on(`${Ranking.EVENT_MOVE_COLUMN}.body`, (col: Column, index: number, old: number) => { - //delete first - const c = this.columns.splice(old, 1)[0]; - console.assert(c.c === col); - // adapt target index based on previous index, i.e shift by one - this.columns.splice(old < index ? index - 1 : index, 0, c); - this.reindex(); - this.delayedUpdateAll(); - }); - ranking.on(`${Ranking.EVENT_COLUMN_VISIBILITY_CHANGED}.body`, (col: Column, _oldValue: boolean, newValue: boolean) => { - if (newValue) { - // become visible - const index = ranking.children.indexOf(col); + //this flag is needed for the CanvasTextureRenderer, since there are multiple EngineRankings used for the same Ranking + //this avoids overriding the event listener of the original EngineRanking from the EngineRenderer + if (addEventListener) { + ranking.on(`${Ranking.EVENT_ADD_COLUMN}.hist`, (col: Column, index: number) => { this.columns.splice(index, 0, this.createCol(col, index)); + this.reindex(); this.updateHist(col); - } else { - // hide - const index = this.columns.findIndex((d) => d.c === col); + this.delayedUpdateAll(); + }); + ranking.on(`${Ranking.EVENT_REMOVE_COLUMN}.body`, (col: Column, index: number) => { EngineRanking.disableListener(col); this.columns.splice(index, 1); - } - this.reindex(); - this.delayedUpdateAll(); - }); - ranking.on(`${Ranking.EVENT_ORDER_CHANGED}.body`, this.delayedUpdate); + this.reindex(); + this.delayedUpdateAll(); + }); + ranking.on(`${Ranking.EVENT_MOVE_COLUMN}.body`, (col: Column, index: number, old: number) => { + //delete first + const c = this.columns.splice(old, 1)[0]; + console.assert(c.c === col); + // adapt target index based on previous index, i.e shift by one + this.columns.splice(old < index ? index - 1 : index, 0, c); + this.reindex(); + this.delayedUpdateAll(); + }); + ranking.on(`${Ranking.EVENT_COLUMN_VISIBILITY_CHANGED}.body`, (col: Column, _oldValue: boolean, newValue: boolean) => { + if (newValue) { + // become visible + const index = ranking.children.indexOf(col); + this.columns.splice(index, 0, this.createCol(col, index)); + this.updateHist(col); + } else { + // hide + const index = this.columns.findIndex((d) => d.c === col); + EngineRanking.disableListener(col); + this.columns.splice(index, 1); + } + this.reindex(); + this.delayedUpdateAll(); + }); + ranking.on(`${Ranking.EVENT_ORDER_CHANGED}.body`, this.delayedUpdate); + } this.selection = new SelectionManager(this.ctx, body); this.selection.on(SelectionManager.EVENT_SELECT_RANGE, (from: number, to: number, additional: boolean) => { @@ -664,6 +668,22 @@ export default class EngineRanking extends ACellTableSection imple }, true); } + updateDetail(detailDataIndices: { has(i: number): boolean }) { + super.forEachRow((node: HTMLElement, rowIndex: number) => { + if (this.renderCtx.isGroup(rowIndex)) { + this.updateRow(node, rowIndex); + return; + } + + const dataIndex = parseInt(node.dataset.i!, 10); + if (detailDataIndices.has(dataIndex)) { + node.classList.add('lu-detail'); + } else { + node.classList.remove('lu-detail'); + } + }, true); + } + updateColumnWidths() { // update the column context in place (this._context).column = nonUniformContext(this._context.columns.map((w) => w.width), 100, COLUMN_PADDING); diff --git a/src/ui/EngineRenderer.ts b/src/ui/EngineRenderer.ts index d1a90a0f3..db07ffc84 100644 --- a/src/ui/EngineRenderer.ts +++ b/src/ui/EngineRenderer.ts @@ -15,6 +15,8 @@ import EngineRanking, {IEngineRankingContext} from './EngineRanking'; import {IRankingHeaderContext, IRankingHeaderContextContainer} from './interfaces'; import SlopeGraph, {EMode} from './SlopeGraph'; import DialogManager from './dialogs/DialogManager'; +import {default as CanvasTextureRenderer, ITextureRenderer} from './CanvasTextureRenderer'; +import * as d3 from 'd3-selection'; import {cssClass} from '../styles/index'; import domElementCache from './domElementCache'; @@ -45,6 +47,10 @@ export default class EngineRenderer extends AEventDispatcher { readonly idPrefix = `lu${Math.random().toString(36).slice(-8).substr(0, 3)}`; //generate a random string with length3; private enabledHighlightListening: boolean = false; + public useTextureRenderer: boolean = false; + private textureRenderer: ITextureRenderer; + private groupPadding: any = null; + private heightsFor: any = null; constructor(protected data: ADataProvider, parent: HTMLElement, options: Readonly) { super(); @@ -128,6 +134,8 @@ export default class EngineRenderer extends AEventDispatcher { }); } + this.textureRenderer = new CanvasTextureRenderer(this.node, this, this.options); + this.initProvider(data); } @@ -177,6 +185,7 @@ export default class EngineRenderer extends AEventDispatcher { private takeDownProvider() { this.data.on(`${ADataProvider.EVENT_SELECTION_CHANGED}.body`, null); + this.data.on(`${ADataProvider.EVENT_DETAIL_CHANGED}.body`, null); this.data.on(`${ADataProvider.EVENT_ADD_RANKING}.body`, null); this.data.on(`${ADataProvider.EVENT_REMOVE_RANKING}.body`, null); this.data.on(`${ADataProvider.EVENT_GROUP_AGGREGATION_CHANGED}.body`, null); @@ -190,6 +199,7 @@ export default class EngineRenderer extends AEventDispatcher { private initProvider(data: ADataProvider) { data.on(`${ADataProvider.EVENT_SELECTION_CHANGED}.body`, () => this.updateSelection(data.getSelection())); + data.on(`${ADataProvider.EVENT_DETAIL_CHANGED}.body`, () => this.updateDetail(data.getDetail())); data.on(`${ADataProvider.EVENT_ADD_RANKING}.body`, (ranking: Ranking) => { this.addRanking(ranking); }); @@ -209,10 +219,16 @@ export default class EngineRenderer extends AEventDispatcher { private updateSelection(dataIndices: number[]) { const s = new Set(dataIndices); this.rankings.forEach((r) => r.updateSelection(s)); - + this.textureRenderer.updateSelection(dataIndices); this.slopeGraphs.forEach((r) => r.updateSelection(s)); } + private updateDetail(dataIndices: number[]) { + const s = new Set(dataIndices); + this.rankings.forEach((r) => r.updateDetail(s)); + this.textureRenderer.update(); + } + private updateHist(ranking?: EngineRanking, col?: Column) { if (!this.options.summaryHeader) { return; @@ -269,9 +285,14 @@ export default class EngineRenderer extends AEventDispatcher { ranking.on(suffix('.renderer', Ranking.EVENT_ORDER_CHANGED), () => this.updateHist(r)); this.rankings.push(r); + this.textureRenderer.addRanking(r); this.update([r]); } + expandTextureRenderer(use: boolean) { + this.textureRenderer.expandTextureRenderer(use); + } + private updateRotatedHeaderState() { if (this.options.labelRotation === 0) { return; @@ -298,6 +319,7 @@ export default class EngineRenderer extends AEventDispatcher { if (slope) { this.table.remove(slope); } + this.textureRenderer.removeRanking(ranking); } update(rankings: EngineRanking[] = this.rankings) { @@ -320,9 +342,9 @@ export default class EngineRenderer extends AEventDispatcher { const round2 = (v: number) => round(v, 2); const rowPadding = round2(this.zoomFactor * this.options.rowPadding!); - const groupPadding = round2(this.zoomFactor * this.options.groupPadding!); + this.groupPadding = round2(this.zoomFactor * this.options.groupPadding!); - const heightsFor = (ranking: Ranking, data: (IGroupItem | IGroupData)[]) => { + this.heightsFor = (ranking: Ranking, data: (IGroupItem | IGroupData)[]) => { if (this.options.dynamicHeight) { const impl = this.options.dynamicHeight(data, ranking); const f = (v: number | any, d: any) => typeof v === 'number' ? v : v(d); @@ -343,20 +365,19 @@ export default class EngineRenderer extends AEventDispatcher { }; }; - rankings.forEach((r, i) => { - const grouped = r.groupData(localData[i]); + if (this.useTextureRenderer) { + this.hide(); + this.textureRenderer.show(); + this.textureRenderer.update(rankings, localData); - const {height, defaultHeight, padding} = heightsFor(r.ranking, grouped); + } else { + this.textureRenderer.hide(); + this.show(); - const rowContext = nonUniformContext(grouped.map(height), defaultHeight, (index) => { - const pad = (typeof padding === 'number' ? padding : padding(grouped[index] || null)); - if (index >= 0 && grouped[index] && (isGroup(grouped[index]) || (grouped[index]).meta === 'last' || (grouped[index]).meta === 'first last')) { - return groupPadding + pad; - } - return pad; + rankings.forEach((r, i) => { + this.render(r, r.groupData(localData[i])); }); - r.render(grouped, rowContext); - }); + } this.updateSlopeGraphs(rankings); @@ -364,6 +385,20 @@ export default class EngineRenderer extends AEventDispatcher { this.table.widthChanged(); } + render(r: EngineRanking, grouped: (IGroupData | IGroupItem)[]) { + const {height, defaultHeight, padding} = this.heightsFor(r.ranking, grouped); + + const that = this; + const rowContext = nonUniformContext(grouped.map(height), defaultHeight, (index) => { + const pad = (typeof padding === 'number' ? padding : padding(grouped[index] || null)); + if (index >= 0 && grouped[index] && (isGroup(grouped[index]) || (grouped[index]).meta === 'last' || (grouped[index]).meta === 'first last')) { + return that.groupPadding + pad; + } + return pad; + }); + r.render(grouped, rowContext); + } + private updateSlopeGraphs(rankings: EngineRanking[] = this.rankings) { const indices = new Set(rankings.map((d) => this.rankings.indexOf(d))); this.slopeGraphs.forEach((s, i) => { @@ -424,7 +459,24 @@ export default class EngineRenderer extends AEventDispatcher { destroy() { this.takeDownProvider(); this.table.destroy(); + this.textureRenderer.destroy(); this.node.remove(); } + + show() { + d3.select(this.node).select('main').style('display', null); + } + + hide() { + d3.select(this.node).select('main').style('display', 'none'); + } + + convertSelectionToDetail() { + this.textureRenderer.convertSelectionToDetail(); + } + + convertDetailToSelection() { + this.textureRenderer.convertDetailToSelection(); + } } diff --git a/src/ui/panel/SidePanel.ts b/src/ui/panel/SidePanel.ts index 8c9dd53e1..7ee70fe43 100644 --- a/src/ui/panel/SidePanel.ts +++ b/src/ui/panel/SidePanel.ts @@ -9,6 +9,7 @@ import { createGroupDesc, createAggregateDesc, createSelectionDesc, + createDetailDesc, IColumnDesc } from '../../model'; import {categoryOfDesc} from '../../model/annotations'; @@ -57,6 +58,7 @@ export default class SidePanel { createImpositionDesc(), createRankDesc(), createSelectionDesc(), + createDetailDesc(), createGroupDesc(), createAggregateDesc(), ], diff --git a/src/ui/taggle/Taggle.ts b/src/ui/taggle/Taggle.ts index 0419db303..28c99740f 100644 --- a/src/ui/taggle/Taggle.ts +++ b/src/ui/taggle/Taggle.ts @@ -6,6 +6,7 @@ import {ALineUp} from '../ALineUp'; import SidePanel from '../panel/SidePanel'; import spaceFillingRule from './spaceFillingRule'; import TaggleRenderer from './TaggleRenderer'; +import LocalDataProvider from '../../provider/LocalDataProvider'; import {cssClass, engineCssClass} from '../../styles/index'; import {GridStyleManager} from 'lineupengine'; @@ -41,28 +42,57 @@ export default class Taggle extends ALineUp { }); this.renderer.pushUpdateAble((ctx) => this.panel!.update(ctx)); this.node.insertBefore(this.panel.node, this.node.firstChild); - { - this.panel.node.insertAdjacentHTML('afterbegin', `