diff --git a/src/etoile/etoile.ts b/src/etoile/etoile.ts index c9d1593..1c15bfd 100644 --- a/src/etoile/etoile.ts +++ b/src/etoile/etoile.ts @@ -12,3 +12,22 @@ export function traverse(graphs: Display[], handler: (graph: S) => void) { } } } + +// https://jhildenbiddle.github.io/canvas-size/#/?id=maxheight +function getCanvasBoundarySize() { + const ua = navigator.userAgent + let size = 16384 + + if (/Firefox\/(\d+)/.test(ua)) { + const version = parseInt(RegExp.$1, 10) + if (version >= 122) { + size = 23168 + } else { + size = 11180 + } + } + + return { size } +} + +export const canvasBoundarySize = getCanvasBoundarySize() diff --git a/src/etoile/graph/display.ts b/src/etoile/graph/display.ts index b02e596..805fe45 100644 --- a/src/etoile/graph/display.ts +++ b/src/etoile/graph/display.ts @@ -187,11 +187,14 @@ export abstract class S extends Display { export abstract class Graph extends S { instruction: ReturnType __options__: Partial + // eslint-disable-next-line @typescript-eslint/no-explicit-any + __widget__: any abstract style: GraphStyleSheet constructor(options: Partial = {}) { super(options) this.instruction = createInstruction() this.__options__ = options + this.__widget__ = null } abstract create(): void abstract clone(): Graph diff --git a/src/etoile/native/dom.ts b/src/etoile/native/dom.ts index abb9e43..29e13ef 100644 --- a/src/etoile/native/dom.ts +++ b/src/etoile/native/dom.ts @@ -95,6 +95,8 @@ export function createEffectScope() { return { run, stop } } +// Some thoughts DOMEvent was designed this way intentionally. I don't have any idea of splitting the general libray yet. +// The follow captureBoxXy matrix a and d be 1 is because of the scaled canvas (without zoomed) is with a new layout. // eslint-disable-next-line @typescript-eslint/no-explicit-any export function bindDOMEvent(el: HTMLElement, evt: DOMEventType | (string & {}), dom: DOMEvent) { const handler = (e: unknown) => { diff --git a/src/primitives/cache.ts b/src/primitives/cache.ts new file mode 100644 index 0000000..32186fc --- /dev/null +++ b/src/primitives/cache.ts @@ -0,0 +1,99 @@ +import { Canvas, asserts, canvasBoundarySize, drawGraphIntoCanvas, traverse } from '../etoile' +import type { RenderViewportOptions } from '../etoile' +import { Matrix2D } from '../etoile/native/matrix' +import { TreemapLayout, resetLayout } from './component' +import type { NativeModule } from './struct' + +export abstract class Cache { + abstract key: string + abstract get state(): boolean + abstract flush(...args: never): void + abstract destroy(): void +} + +// The following is my opinionated. +// For better performance, we desgin a cache system to store the render result. +// two step +// 1. draw current canvas into a cache canvas (offscreen canvas) +// 2. draw cache canvas into current canvas (note we should respect the dpi) +export class RenderCache extends Canvas implements Cache { + key: string + private $memory: boolean + constructor(opts: RenderViewportOptions) { + super(opts) + this.key = 'render-cache' + this.$memory = false + } + get state() { + return this.$memory + } + flush(treemap: TreemapLayout, matrix = new Matrix2D()) { + const { devicePixelRatio, width, height } = treemap.render.options + const { a, d } = matrix + const { size } = canvasBoundarySize + // Check outof boundary + if (width * a >= size || height * d >= size) { + return + } + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) + this.setOptions({ width: width * a, height: height * d, devicePixelRatio }) + resetLayout(treemap, width * a, height * d) + drawGraphIntoCanvas(treemap, { c: this.canvas, ctx: this.ctx, dpr: devicePixelRatio }) + this.$memory = true + } + destroy() { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) + this.$memory = false + } +} + +export class FontCache implements Cache { + key: string + private fonts: Record + ellispsis: Record + constructor() { + this.key = 'font-cache' + this.fonts = {} + this.ellispsis = {} + } + get state() { + return true + } + flush(treemap: TreemapLayout, matrix = new Matrix2D()) { + const { width, height } = treemap.render.options + const { a, d } = matrix + + const zoomedWidth = width * a + const zoomedHeight = height * d + if (zoomedWidth <= width || zoomedHeight <= height) { + return + } + traverse([treemap.elements[0]], (graph) => { + if (asserts.isRoundRect(graph)) { + const { x, y, height: graphHeight, width: graphWidth } = graph + if (!graphHeight || !graphWidth) { + return + } + if (x >= 0 && y >= 0 && (x + graphWidth) <= width && (y + graphHeight) <= height) { + if (graph.__widget__) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const node = graph.__widget__.node as unknown as NativeModule + delete this.fonts[node.id] + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + delete this.ellispsis[node.label] + } + } + } + }) + } + destroy() { + this.fonts = {} + this.ellispsis = {} + } + queryFontById(id: string, byDefault: () => number) { + if (!(id in this.fonts)) { + this.fonts[id] = byDefault() + } + return this.fonts[id] + } +} diff --git a/src/primitives/component.ts b/src/primitives/component.ts index 1fef794..6ddf7a3 100644 --- a/src/primitives/component.ts +++ b/src/primitives/component.ts @@ -1,10 +1,9 @@ /* eslint-disable no-use-before-define */ -import { Bitmap, Box, Canvas, Schedule, drawGraphIntoCanvas } from '../etoile' -import type { RenderViewportOptions } from '../etoile' +import { Bitmap, Box, Schedule } from '../etoile' import type { DOMEventDefinition } from '../etoile/native/dom' import { log } from '../etoile/native/log' -import { Matrix2D } from '../etoile/native/matrix' import { createRoundBlock, createTitleText } from '../shared' +import { FontCache, RenderCache } from './cache' import type { RenderDecorator, Series } from './decorator' import type { ExposedEventMethods, InternalEventDefinition } from './event' import { INTERNAL_EVENT_MAPPINGS, TreemapEvent } from './event' @@ -132,10 +131,9 @@ export class TreemapLayout extends Schedule { decorator: RenderDecorator bgBox: Box fgBox: Box - fontsCaches: Record - ellispsisWidthCache: Record highlight: Highlight renderCache: RenderCache + fontCache: FontCache constructor(...args: ConstructorParameters) { super(...args) this.data = [] @@ -143,10 +141,9 @@ export class TreemapLayout extends Schedule { this.bgBox = new Box() this.fgBox = new Box() this.decorator = Object.create(null) as RenderDecorator - this.fontsCaches = Object.create(null) as Record - this.ellispsisWidthCache = Object.create(null) as Record this.highlight = new Highlight(this.to, { width: this.render.options.width, height: this.render.options.height }) this.renderCache = new RenderCache(this.render.options) + this.fontCache = new FontCache() } drawBackgroundNode(node: LayoutModule) { @@ -156,7 +153,9 @@ export class TreemapLayout extends Schedule { return } const fill = this.decorator.color.mappings[node.node.id] - this.bgBox.add(createRoundBlock(x, y, w, h, { fill, padding, radius: 2 })) + const rect = createRoundBlock(x, y, w, h, { fill, padding, radius: 2 }) + rect.__widget__ = node + this.bgBox.add(rect) for (const child of node.children) { this.drawBackgroundNode(child) } @@ -167,11 +166,8 @@ export class TreemapLayout extends Schedule { if (!w || !h) { return } const { titleHeight, rectGap } = node.decorator const { fontSize, fontFamily, color } = this.decorator.font - let optimalFontSize - if (node.node.id in this.fontsCaches) { - optimalFontSize = this.fontsCaches[node.node.id] - } else { - optimalFontSize = evaluateOptimalFontSize( + const optimalFontSize = this.fontCache.queryFontById(node.node.id, () => + evaluateOptimalFontSize( this.render.ctx, // eslint-disable-next-line @typescript-eslint/no-unsafe-argument node.node.label, @@ -181,12 +177,12 @@ export class TreemapLayout extends Schedule { }, w - (rectGap * 2), node.children.length ? Math.round(titleHeight / 2) + rectGap : h - ) - this.fontsCaches[node.node.id] = optimalFontSize - } + )) + this.render.ctx.font = `${optimalFontSize}px ${fontFamily}` + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const result = getSafeText(this.render.ctx, node.node.label, w - (rectGap * 2), this.ellispsisWidthCache) + const result = getSafeText(this.render.ctx, node.node.label, w - (rectGap * 2), this.fontCache.ellispsis) if (!result) { return } if (result.width >= w || optimalFontSize >= h) { return } const { text, width } = result @@ -201,7 +197,6 @@ export class TreemapLayout extends Schedule { reset(refresh = false) { this.remove(this.bgBox, this.fgBox) this.bgBox.destory() - if (this.renderCache.state) { this.fgBox.destory() this.bgBox.add(new Bitmap({ bitmap: this.renderCache.canvas })) @@ -219,7 +214,6 @@ export class TreemapLayout extends Schedule { this.fgBox = this.fgBox.clone() } } - this.add(this.bgBox, this.fgBox) } @@ -271,11 +265,10 @@ export function createTreemap() { function resize() { if (!treemap || !root) { return } treemap.renderCache.destroy() + treemap.fontCache.destroy() const { width, height } = root.getBoundingClientRect() treemap.render.initOptions({ height, width, devicePixelRatio: window.devicePixelRatio }) treemap.render.canvas.style.position = 'absolute' - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - treemap.fontsCaches = Object.create(null) treemap.event.emit(INTERNAL_EVENT_MAPPINGS.ON_CLEANUP) treemap.highlight.render.initOptions({ height, width, devicePixelRatio: window.devicePixelRatio }) treemap.highlight.reset() @@ -319,34 +312,3 @@ export function createTreemap() { } export type TreemapInstanceAPI = TreemapLayout['api'] - -// The following is my opinionated. -// For better performance, we desgin a cache system to store the render result. -// two step -// 1. draw current canvas into a cache canvas (offscreen canvas) -// 2. draw cache canvas into current canvas (note we should respect the dpi) -export class RenderCache extends Canvas { - key: string - private $memory: boolean - constructor(opts: RenderViewportOptions) { - super(opts) - this.key = 'render-cache' - this.$memory = false - } - get state() { - return this.$memory - } - flush(treemap: TreemapLayout, matrix = new Matrix2D({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 })) { - const { devicePixelRatio, width, height } = treemap.render.options - const { a, d } = matrix - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) - this.setOptions({ width: width * a, height: height * d, devicePixelRatio }) - resetLayout(treemap, width * a, height * d) - drawGraphIntoCanvas(treemap, { c: this.canvas, ctx: this.ctx, dpr: devicePixelRatio }) - this.$memory = true - } - destroy() { - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) - this.$memory = false - } -} diff --git a/src/primitives/decorator.ts b/src/primitives/decorator.ts index b758dc7..3ad222a 100644 --- a/src/primitives/decorator.ts +++ b/src/primitives/decorator.ts @@ -49,7 +49,7 @@ export const defaultLayoutOptions = { export const defaultFontOptions = { color: '#000', fontSize: { - max: 38, + max: 70, min: 0 }, fontFamily: 'sans-serif' diff --git a/src/primitives/event.ts b/src/primitives/event.ts index 343c0cc..60a2ab6 100644 --- a/src/primitives/event.ts +++ b/src/primitives/event.ts @@ -294,6 +294,7 @@ export class TreemapEvent extends DOMEvent { const translateX = offsetX - (offsetX - this.matrix.e) * delta const translateY = offsetY - (offsetY - this.matrix.f) * delta runEffect((progress) => { + treemap.fontCache.flush(treemap, this.matrix) this.state.isWheeling = true const easedProgress = easing.quadraticOut(progress) const scale = (targetScaleRatio - this.matrix.a) * easedProgress @@ -308,6 +309,7 @@ export class TreemapEvent extends DOMEvent { onStop: () => { this.state.forceDestroy = false this.state.isWheeling = false + treemap.fontCache.flush(treemap, this.matrix) } }) } @@ -380,8 +382,8 @@ function createOnZoom(treemap: TreemapLayout, evt: TreemapEvent) { const c = treemap.render.canvas const boundingClientRect = c.getBoundingClientRect() const [w, h] = estimateZoomingArea(node, root, boundingClientRect.width, boundingClientRect.height) - delete treemap.fontsCaches[node.node.id] - delete treemap.ellispsisWidthCache[node.node.id] + // delete treemap.fontsCaches[node.node.id] + // delete treemap.ellispsisWidthCache[node.node.id] resetLayout(treemap, w, h) const module = findRelativeNodeById(node.node.id, treemap.layoutNodes) if (module) {