From 581e36b43baddda7480a26b99ae0aae5307e09f9 Mon Sep 17 00:00:00 2001 From: Dominik Dirmeier Date: Sun, 24 Jun 2018 22:47:34 +0200 Subject: [PATCH] implementation of a canvas texture renderer, for Caleydo/lineup_app#3 and Caleydo/lineup_app#4 --- src/styles/engine/_index.scss | 1 + src/styles/engine/_texture_renderer.scss | 15 ++ src/ui/CanvasTextureRenderer.ts | 170 +++++++++++++++++++++++ src/ui/EngineRenderer.ts | 91 +++++++----- src/ui/taggle/Taggle.ts | 3 +- src/ui/taggle/TaggleRenderer.ts | 5 + 6 files changed, 251 insertions(+), 34 deletions(-) create mode 100644 src/styles/engine/_texture_renderer.scss create mode 100644 src/ui/CanvasTextureRenderer.ts diff --git a/src/styles/engine/_index.scss b/src/styles/engine/_index.scss index 9fa845400..5b736e0f1 100644 --- a/src/styles/engine/_index.scss +++ b/src/styles/engine/_index.scss @@ -13,3 +13,4 @@ $engine_assets: '~lineupengine/src/assets'; @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..50122a6f8 --- /dev/null +++ b/src/styles/engine/_texture_renderer.scss @@ -0,0 +1,15 @@ +@import '../vars'; + +##{$lu-css_prefix}-texture-container { + display: flex; + overflow-y: hidden; + overflow-x: auto; + + img { + margin-right: 5px; + + &.partOfComposite { + margin-right: 6px; + } + } +} diff --git a/src/ui/CanvasTextureRenderer.ts b/src/ui/CanvasTextureRenderer.ts new file mode 100644 index 000000000..74b89f321 --- /dev/null +++ b/src/ui/CanvasTextureRenderer.ts @@ -0,0 +1,170 @@ +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 * as d3 from 'd3-selection'; + +export interface ITextureRenderer { + update(rankings: EngineRanking[], localData: IDataRow[][]): void; + destroy(): void; + show(): void; + hide(): void; +} + +export default class CanvasTextureRenderer implements ITextureRenderer { + + readonly node: HTMLElement; + readonly canvas: any; + readonly headerNode: HTMLElement; + private renderedColumns: any[]; + + constructor(parent: Element) { + this.node = parent.ownerDocument.createElement('main'); + this.node.id = 'lu-texture-container'; + parent.appendChild(this.node); + this.canvas = parent.ownerDocument.createElement('canvas'); + this.headerNode = d3.select(parent).select('header').node(); + this.renderedColumns = []; + + this.node.addEventListener('scroll', () => { + { + //scroll header with main panel + this.headerNode.scrollLeft = this.node.scrollLeft; + } + }); + } + + update(rankings: EngineRanking[], localData: IDataRow[][]) { + this.node.innerHTML = ''; //remove all children + this.renderedColumns = []; + + rankings.forEach((r, i) => { + const grouped = r.groupData(localData[i]); + + r.ranking.flatColumns.forEach((column) => this.createColumn(column, grouped, false)); + }); + } + + private createColumn(column: Column, grouped: any[], partOfComposite: boolean) { + if (this.renderedColumns.includes(column.id)) { + if (partOfComposite) { + const $node = d3.select(this.node); + const $img = $node.select(`img[data-columnid="${column.id}"]`).node(); + if ($img !== null) { + $node.append(() => $img); //reorder the column + return; + } + } else { + return; //column already rendered + } + } + let newElement = null; + if (column instanceof NumbersColumn) { + const col = column; + newElement = this.generateImage(grouped.map((value) => { + return (value).v[(col.desc).column]; + }), CanvasTextureRenderer.getColorScale(col)); + } else if (column instanceof NumberColumn) { + const col = column; + newElement = this.generateImage(grouped.map((value) => { + return [(value).v[(col.desc).column]]; + }), CanvasTextureRenderer.getColorScale(col)); + } else if (column instanceof CategoricalsColumn) { + const col = column; + newElement = this.generateImage(grouped.map((value) => { + return (value).v[(col.desc).column]; + }), CanvasTextureRenderer.getColorScale(col)); + } else if (column instanceof CategoricalColumn) { + const col = column; + newElement = this.generateImage(grouped.map((value) => { + return [(value).v[(col.desc).column]]; + }), CanvasTextureRenderer.getColorScale(col)); + } else if ('children' in column) { + //handle composite columns + (column).children.forEach((c) => this.createColumn(c, grouped, true)); + return; + } else { + newElement = this.node.ownerDocument.createElement('img'); + } + + newElement.style.width = `${column.getWidth()}px`; + newElement.setAttribute('data-columnid', column.id); + if (partOfComposite) { + newElement.classList.add('partOfComposite'); + } + this.node.appendChild(newElement); + 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.value)) + .range(categories.map((v) => v.color)); + return colorScale; + } + return null; + } + + private generateImage(data: any[][], colorScale: any) { + const height = data.length; + let width = 0; + if(height > 0) { + width = data[0].length; + } + + this.canvas.setAttribute('height', `${height}`); + this.canvas.setAttribute('width', `${width}`); + + if (colorScale !== null) { + const ctx = this.canvas.getContext('2d'); + data.forEach((row, y) => { + row.forEach((value, x) => { + ctx.fillStyle = colorScale(value); + ctx.fillRect(x, y, 1, 1); + }); + }); + ctx.save(); + } + + const image = this.node.ownerDocument.createElement('img'); + image.src = this.canvas.toDataURL(); + + return image; + } + + destroy() { + this.node.remove(); + } + + show() { + this.node.style.display = null; + } + + hide() { + this.node.style.display = 'none'; + } +} + diff --git a/src/ui/EngineRenderer.ts b/src/ui/EngineRenderer.ts index a86112b5e..6f76e3ec3 100644 --- a/src/ui/EngineRenderer.ts +++ b/src/ui/EngineRenderer.ts @@ -16,6 +16,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'; export default class EngineRenderer extends AEventDispatcher { @@ -37,6 +39,8 @@ 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; constructor(protected data: ADataProvider, parent: HTMLElement, options: Readonly) { super(); @@ -133,6 +137,8 @@ export default class EngineRenderer extends AEventDispatcher { }`); } + this.textureRenderer = new CanvasTextureRenderer(this.node); + this.initProvider(data); } @@ -316,45 +322,55 @@ export default class EngineRenderer extends AEventDispatcher { this.updateHist(); } - const round2 = (v: number) => round(v, 2); - const rowPadding = round2(this.zoomFactor * this.options.rowPadding!); - const groupPadding = round2(this.zoomFactor * this.options.groupPadding!); - - const 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); - if (impl) { - return { - defaultHeight: round2(this.zoomFactor * impl.defaultHeight), - height: (d: IGroupItem | IGroupData) => round2(this.zoomFactor * f(impl.height, d)), - padding: (d: IGroupItem | IGroupData) => round2(this.zoomFactor * f(impl.padding, d)), - }; + if (this.useTextureRenderer) { + this.hide(); + this.textureRenderer.show(); + this.textureRenderer.update(rankings, localData); + + } else { + this.textureRenderer.hide(); + this.show(); + + const round2 = (v: number) => round(v, 2); + const rowPadding = round2(this.zoomFactor * this.options.rowPadding!); + const groupPadding = round2(this.zoomFactor * this.options.groupPadding!); + + const 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); + if (impl) { + return { + defaultHeight: round2(this.zoomFactor * impl.defaultHeight), + height: (d: IGroupItem | IGroupData) => round2(this.zoomFactor * f(impl.height, d)), + padding: (d: IGroupItem | IGroupData) => round2(this.zoomFactor * f(impl.padding, d)), + }; + } } - } - const item = round2(this.zoomFactor * this.options.rowHeight!); - const group = round2(this.zoomFactor * this.options.groupHeight!); - return { - defaultHeight: item, - height: (d: IGroupItem | IGroupData) => isGroup(d) ? group : item, - padding: rowPadding + const item = round2(this.zoomFactor * this.options.rowHeight!); + const group = round2(this.zoomFactor * this.options.groupHeight!); + return { + defaultHeight: item, + height: (d: IGroupItem | IGroupData) => isGroup(d) ? group : item, + padding: rowPadding + }; }; - }; - rankings.forEach((r, i) => { - const grouped = r.groupData(localData[i]); + rankings.forEach((r, i) => { + const grouped = r.groupData(localData[i]); - const {height, defaultHeight, padding} = heightsFor(r.ranking, grouped); + const {height, defaultHeight, padding} = heightsFor(r.ranking, grouped); - 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; + 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; + }); + r.render(grouped, rowContext); }); - r.render(grouped, rowContext); - }); + } this.updateSlopeGraphs(rankings); @@ -422,7 +438,16 @@ 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'); + } } diff --git a/src/ui/taggle/Taggle.ts b/src/ui/taggle/Taggle.ts index 3af0a8589..222093b95 100644 --- a/src/ui/taggle/Taggle.ts +++ b/src/ui/taggle/Taggle.ts @@ -42,7 +42,8 @@ export default class Taggle extends ALineUp { const input = this.spaceFilling.querySelector('input'); input.onchange = () => { const selected = this.spaceFilling.classList.toggle('chosen'); - self.setTimeout(() => this.renderer.switchRule(selected ? spaceFilling : null)); + //self.setTimeout(() => this.renderer.switchRule(selected ? spaceFilling : null)); + this.renderer.useTextureRenderer(selected); }; if (this.options.overviewMode) { input.checked = true; diff --git a/src/ui/taggle/TaggleRenderer.ts b/src/ui/taggle/TaggleRenderer.ts index 8d1747685..251333ac2 100644 --- a/src/ui/taggle/TaggleRenderer.ts +++ b/src/ui/taggle/TaggleRenderer.ts @@ -135,6 +135,11 @@ export default class TaggleRenderer extends AEventDispatcher { this.update(); } + useTextureRenderer(use: boolean) { + this.renderer.useTextureRenderer = use; + this.update(); + } + destroy() { this.renderer.destroy(); window.removeEventListener('resize', this.resizeListener);