UNPKG

squarified

Version:
1,690 lines (1,671 loc) 51.2 kB
const DEG_TO_RAD = Math.PI / 180; const PI_2 = Math.PI * 2; const DEFAULT_MATRIX_LOC = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; class Matrix2D { a; b; c; d; e; f; constructor(loc = {}){ this.a = loc.a || 1; this.b = loc.b || 0; this.c = loc.c || 0; this.d = loc.d || 1; this.e = loc.e || 0; this.f = loc.f || 0; } create(loc) { Object.assign(this, loc); return this; } transform(x, y, scaleX, scaleY, rotation, skewX, skewY) { this.scale(scaleX, scaleY).translation(x, y); if (skewX || skewY) { this.skew(skewX, skewY); } else { this.roate(rotation); } return this; } translation(x, y) { this.e += x; this.f += y; return this; } scale(a, d) { this.a *= a; this.d *= d; return this; } skew(x, y) { const tanX = Math.tan(x * DEG_TO_RAD); const tanY = Math.tan(y * DEG_TO_RAD); const a = this.a + this.b * tanX; const b = this.b + this.a * tanY; const c = this.c + this.d * tanX; const d = this.d + this.c * tanY; this.a = a; this.b = b; this.c = c; this.d = d; return this; } roate(rotation) { if (rotation > 0) { const rad = rotation * DEG_TO_RAD; const cosTheta = Math.cos(rad); const sinTheta = Math.sin(rad); const a = this.a * cosTheta - this.b * sinTheta; const b = this.a * sinTheta + this.b * cosTheta; const c = this.c * cosTheta - this.d * sinTheta; const d = this.c * sinTheta + this.d * cosTheta; this.a = a; this.b = b; this.c = c; this.d = d; } return this; } } const SELF_ID = { id: 0, get () { return this.id++; } }; var DisplayType = /*#__PURE__*/ function(DisplayType) { DisplayType["Graph"] = "Graph"; DisplayType["Box"] = "Box"; DisplayType["Text"] = "Text"; DisplayType["RoundRect"] = "RoundRect"; return DisplayType; }({}); class Display { parent; id; matrix; constructor(){ this.parent = null; this.id = SELF_ID.get(); this.matrix = new Matrix2D(); } destory() { // } } const ASSIGN_MAPPINGS = { fillStyle: 0o1, strokeStyle: 0o2, font: 0o4, lineWidth: 0o10, textAlign: 0o20, textBaseline: 0o40 }; const ASSIGN_MAPPINGS_MODE = ASSIGN_MAPPINGS.fillStyle | ASSIGN_MAPPINGS.strokeStyle | ASSIGN_MAPPINGS.font | ASSIGN_MAPPINGS.lineWidth | ASSIGN_MAPPINGS.textAlign | ASSIGN_MAPPINGS.textBaseline; const CALL_MAPPINGS_MODE = 0o0; function createInstruction() { return { mods: [], fillStyle (...args) { this.mods.push({ mod: [ 'fillStyle', args ], type: ASSIGN_MAPPINGS.fillStyle }); }, fillRect (...args) { this.mods.push({ mod: [ 'fillRect', args ], type: CALL_MAPPINGS_MODE }); }, strokeStyle (...args) { this.mods.push({ mod: [ 'strokeStyle', args ], type: ASSIGN_MAPPINGS.strokeStyle }); }, lineWidth (...args) { this.mods.push({ mod: [ 'lineWidth', args ], type: ASSIGN_MAPPINGS.lineWidth }); }, strokeRect (...args) { this.mods.push({ mod: [ 'strokeRect', args ], type: CALL_MAPPINGS_MODE }); }, fillText (...args) { this.mods.push({ mod: [ 'fillText', args ], type: CALL_MAPPINGS_MODE }); }, font (...args) { this.mods.push({ mod: [ 'font', args ], type: ASSIGN_MAPPINGS.font }); }, textBaseline (...args) { this.mods.push({ mod: [ 'textBaseline', args ], type: ASSIGN_MAPPINGS.textBaseline }); }, textAlign (...args) { this.mods.push({ mod: [ 'textAlign', args ], type: ASSIGN_MAPPINGS.textAlign }); }, beginPath () { this.mods.push({ mod: [ 'beginPath', [] ], type: CALL_MAPPINGS_MODE }); }, moveTo (...args) { this.mods.push({ mod: [ 'moveTo', args ], type: CALL_MAPPINGS_MODE }); }, arcTo (...args) { this.mods.push({ mod: [ 'arcTo', args ], type: CALL_MAPPINGS_MODE }); }, closePath () { this.mods.push({ mod: [ 'closePath', [] ], type: CALL_MAPPINGS_MODE }); }, fill () { this.mods.push({ mod: [ 'fill', [] ], type: CALL_MAPPINGS_MODE }); }, stroke () { this.mods.push({ mod: [ 'stroke', [] ], type: CALL_MAPPINGS_MODE }); }, drawImage (...args) { // @ts-expect-error safe this.mods.push({ mod: [ 'drawImage', args ], type: CALL_MAPPINGS_MODE }); } }; } class S extends Display { width; height; x; y; scaleX; scaleY; rotation; skewX; skewY; constructor(options = {}){ super(); this.width = options.width || 0; this.height = options.height || 0; this.x = options.x || 0; this.y = options.y || 0; this.scaleX = options.scaleX || 1; this.scaleY = options.scaleY || 1; this.rotation = options.rotation || 0; this.skewX = options.skewX || 0; this.skewY = options.skewY || 0; } } // For performance. we need impl AABB Check for render. class Graph extends S { instruction; __options__; constructor(options = {}){ super(options); this.instruction = createInstruction(); this.__options__ = options; } render(ctx) { this.create(); const cap = this.instruction.mods.length; for(let i = 0; i < cap; i++){ const { mod, type } = this.instruction.mods[i]; const [direct, ...args] = mod; if (type & ASSIGN_MAPPINGS_MODE) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error ctx[direct] = args[0]; continue; } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error // eslint-disable-next-line @typescript-eslint/no-unsafe-call ctx[direct].apply(ctx, ...args); } } get __instanceOf__() { return "Graph"; } } function isGraph(display) { return display.__instanceOf__ === DisplayType.Graph; } function isBox(display) { return display.__instanceOf__ === DisplayType.Box; } function isRoundRect(display) { return isGraph(display) && display.__shape__ === DisplayType.RoundRect; } function isText(display) { return isGraph(display) && display.__shape__ === DisplayType.Text; } const asserts = { isGraph, isBox, isText, isRoundRect }; class C extends Display { elements; constructor(){ super(); this.elements = []; } add(...elements) { const cap = elements.length; for(let i = 0; i < cap; i++){ const element = elements[i]; if (element.parent) ; this.elements.push(element); element.parent = this; } } remove(...elements) { const cap = elements.length; for(let i = 0; i < cap; i++){ for(let j = this.elements.length - 1; j >= 0; j--){ const element = this.elements[j]; if (element.id === elements[i].id) { this.elements.splice(j, 1); element.parent = null; } } } } destory() { this.elements.forEach((element)=>element.parent = null); this.elements.length = 0; } } class Box extends C { elements; constructor(){ super(); this.elements = []; } add(...elements) { const cap = elements.length; for(let i = 0; i < cap; i++){ const element = elements[i]; if (element.parent) ; this.elements.push(element); element.parent = this; } } remove(...elements) { const cap = elements.length; for(let i = 0; i < cap; i++){ for(let j = this.elements.length - 1; j >= 0; j--){ const element = this.elements[j]; if (element.id === elements[i].id) { this.elements.splice(j, 1); element.parent = null; } } } } destory() { this.elements.forEach((element)=>element.parent = null); this.elements.length = 0; } get __instanceOf__() { return DisplayType.Box; } clone() { const box = new Box(); if (this.elements.length) { const stack = [ { elements: this.elements, parent: box } ]; while(stack.length > 0){ const { elements, parent } = stack.pop(); const cap = elements.length; for(let i = 0; i < cap; i++){ const element = elements[i]; if (asserts.isBox(element)) { const newBox = new Box(); newBox.parent = parent; parent.add(newBox); stack.push({ elements: element.elements, parent: newBox }); } else if (asserts.isGraph(element)) { const el = element.clone(); el.parent = parent; parent.add(el); } } } } return box; } } // Runtime is designed for graph element function decodeHLS(meta) { const { h, l, s, a } = meta; if ('a' in meta) { return `hsla(${h}deg, ${s}%, ${l}%, ${a})`; } return `hsl(${h}deg, ${s}%, ${l}%)`; } function decodeRGB(meta) { const { r, g, b, a } = meta; if ('a' in meta) { return `rgba(${r}, ${g}, ${b}, ${a})`; } return `rgb(${r}, ${g}, ${b})`; } function decodeColor(meta) { return meta.mode === 'rgb' ? decodeRGB(meta.desc) : decodeHLS(meta.desc); } function evaluateFillStyle(primitive, opacity = 1) { const descibe = { mode: primitive.mode, desc: { ...primitive.desc, a: opacity } }; return decodeColor(descibe); } const runtime = { evaluateFillStyle }; class RoundRect extends Graph { style; constructor(options = {}){ super(options); this.style = options.style || Object.create(null); } get __shape__() { return DisplayType.RoundRect; } create() { const padding = this.style.padding; const x = 0; const y = 0; const width = this.width - padding * 2; const height = this.height - padding * 2; const radius = this.style.radius || 0; this.instruction.beginPath(); this.instruction.moveTo(x + radius, y); this.instruction.arcTo(x + width, y, x + width, y + height, radius); this.instruction.arcTo(x + width, y + height, x, y + height, radius); this.instruction.arcTo(x, y + height, x, y, radius); this.instruction.arcTo(x, y, x + width, y, radius); this.instruction.closePath(); if (this.style.fill) { this.instruction.closePath(); this.instruction.fillStyle(runtime.evaluateFillStyle(this.style.fill, this.style.opacity)); this.instruction.fill(); } if (this.style.stroke) { if (typeof this.style.lineWidth === 'number') { this.instruction.lineWidth(this.style.lineWidth); } this.instruction.strokeStyle(this.style.stroke); this.instruction.stroke(); } } clone() { return new RoundRect({ ...this.style, ...this.__options__ }); } } class Text extends Graph { text; style; constructor(options = {}){ super(options); this.text = options.text || ''; this.style = options.style || Object.create(null); } create() { if (this.style.fill) { this.instruction.font(this.style.font); this.instruction.lineWidth(this.style.lineWidth); this.instruction.textBaseline(this.style.baseline); this.instruction.textAlign(this.style.textAlign); this.instruction.fillStyle(this.style.fill); this.instruction.fillText(this.text, 0, 0); } } clone() { return new Text({ ...this.style, ...this.__options__ }); } get __shape__() { return DisplayType.Text; } } function traverse(graphs, handler) { const len = graphs.length; for(let i = 0; i < len; i++){ const graph = graphs[i]; if (asserts.isGraph(graph)) { handler(graph); } else if (asserts.isBox(graph)) { traverse(graph.elements, handler); } } } class Event { eventCollections; constructor(){ this.eventCollections = Object.create(null); } on(evt, handler, c) { if (!(evt in this.eventCollections)) { this.eventCollections[evt] = []; } const data = { name: evt, handler, ctx: c || this, silent: false }; this.eventCollections[evt].push(data); } off(evt, handler) { if (evt in this.eventCollections) { if (!handler) { this.eventCollections[evt] = []; return; } this.eventCollections[evt] = this.eventCollections[evt].filter((d)=>d.handler !== handler); } } silent(evt, handler) { if (!(evt in this.eventCollections)) { return; } this.eventCollections[evt].forEach((d)=>{ if (!handler || d.handler === handler) { d.silent = true; } }); } active(evt, handler) { if (!(evt in this.eventCollections)) { return; } this.eventCollections[evt].forEach((d)=>{ if (!handler || d.handler === handler) { d.silent = false; } }); } emit(evt, ...args) { if (!this.eventCollections[evt]) { return; } const handlers = this.eventCollections[evt]; if (handlers.length) { handlers.forEach((d)=>{ if (d.silent) { return; } d.handler.call(d.ctx, ...args); }); } } bindWithContext(c) { return (evt, handler)=>this.on(evt, handler, c); } } function getOffset(el) { let e = 0; let f = 0; if (document.documentElement.getBoundingClientRect && el.getBoundingClientRect) { const { top, left } = el.getBoundingClientRect(); e = top; f = left; } else { for(let elt = el; elt; elt = el.offsetParent){ e += el.offsetLeft; f += el.offsetTop; } } return [ e + Math.max(document.documentElement.scrollLeft, document.body.scrollLeft), f + Math.max(document.documentElement.scrollTop, document.body.scrollTop) ]; } function captureBoxXY(c, evt, a, d, translateX, translateY) { const boundingClientRect = c.getBoundingClientRect(); if (evt instanceof MouseEvent) { const [e, f] = getOffset(c); return { x: (evt.clientX - boundingClientRect.left - e - translateX) / a, y: (evt.clientY - boundingClientRect.top - f - translateY) / d }; } return { x: 0, y: 0 }; } function createEffectRun(c) { return (fn)=>{ const effect = ()=>{ const done = fn(); if (!done) { c.animationFrameID = raf(effect); } }; if (!c.animationFrameID) { c.animationFrameID = raf(effect); } }; } function createEffectStop(c) { return ()=>{ if (c.animationFrameID) { window.cancelAnimationFrame(c.animationFrameID); c.animationFrameID = null; } }; } function createSmoothFrame() { const c = { animationFrameID: null }; const run = createEffectRun(c); const stop = createEffectStop(c); return { run, stop }; } function hashCode(str) { let hash = 0; for(let i = 0; i < str.length; i++){ const code = str.charCodeAt(i); hash = (hash << 5) - hash + code; hash = hash & hash; } return hash; } // For strings we only check the first character to determine if it's a number (I think it's enough) function perferNumeric(s) { if (typeof s === 'number') { return true; } return s.charCodeAt(0) >= 48 && s.charCodeAt(0) <= 57; } function noop() {} function createRoundBlock(x, y, width, height, style) { return new RoundRect({ width, height, x, y, style: { ...style } }); } function createTitleText(text, x, y, font, color) { return new Text({ text, x, y, style: { fill: color, textAlign: 'center', baseline: 'middle', font, lineWidth: 1 } }); } const raf = window.requestAnimationFrame; function createCanvasElement() { return document.createElement('canvas'); } function applyCanvasTransform(ctx, matrix, dpr) { ctx.setTransform(matrix.a * dpr, matrix.b * dpr, matrix.c * dpr, matrix.d * dpr, matrix.e * dpr, matrix.f * dpr); } function mixin(app, methods) { methods.forEach(({ name, fn })=>{ Object.defineProperty(app, name, { value: fn(app), writable: false, enumerable: true }); }); return app; } function typedForIn(obj, callback) { for(const key in obj){ if (Object.prototype.hasOwnProperty.call(obj, key)) { callback(key, obj[key]); } } } function stackMatrixTransform(graph, e, f, scale) { graph.x = graph.x * scale + e; graph.y = graph.y * scale + f; graph.scaleX = scale; graph.scaleY = scale; } function stackMatrixTransformWithGraphAndLayer(graphs, e, f, scale) { traverse(graphs, (graph)=>stackMatrixTransform(graph, e, f, scale)); } function smoothFrame(callback, opts) { const frame = createSmoothFrame(); const startTime = Date.now(); const condtion = (process)=>{ if (Array.isArray(opts.deps)) { return opts.deps.some((dep)=>dep()); } return process >= 1; }; frame.run(()=>{ const elapsed = Date.now() - startTime; const progress = Math.min(elapsed / opts.duration, 1); if (condtion(progress)) { frame.stop(); if (opts.onStop) { opts.onStop(); } return true; } return callback(progress, frame.stop); }); } function isScrollWheelOrRightButtonOnMouseupAndDown(e) { return e.which === 2 || e.which === 3; } class DefaultMap extends Map { defaultFactory; constructor(defaultFactory, entries){ super(entries); this.defaultFactory = defaultFactory; } get(key) { if (!super.has(key)) { return this.defaultFactory(); } return super.get(key); } getOrInsert(key, value) { if (!super.has(key)) { const defaultValue = value || this.defaultFactory(); super.set(key, defaultValue); return defaultValue; } return super.get(key); } } const createLogger = (namespace)=>{ return { error: (message)=>{ return console.error(`[${namespace}] ${message}`); }, panic: (message)=>{ throw new Error(`[${namespace}] ${message}`); } }; }; function assertExists(value, logger, message) { if (value === null || value === undefined) { logger.panic(message); } } const NAME_SPACE = 'etoile'; const log = createLogger(NAME_SPACE); function writeBoundingRectForCanvas(c, w, h, dpr) { c.width = w * dpr; c.height = h * dpr; c.style.cssText = `width: ${w}px; height: ${h}px`; } class Canvas { canvas; ctx; constructor(options){ this.canvas = createCanvasElement(); this.setOptions(options); this.ctx = this.canvas.getContext('2d'); } setOptions(options) { writeBoundingRectForCanvas(this.canvas, options.width, options.height, options.devicePixelRatio); } } class Render { options; container; constructor(to, options){ this.container = new Canvas(options); this.options = options; this.initOptions(options); if (!options.shaow) { to.appendChild(this.container.canvas); } } clear(width, height) { this.ctx.clearRect(0, 0, width, height); } get canvas() { return this.container.canvas; } get ctx() { return this.container.ctx; } initOptions(userOptions = {}) { Object.assign(this.options, userOptions); this.container.setOptions(this.options); } destory() {} } // First cleanup canvas function drawGraphIntoCanvas(graph, opts) { const { ctx, dpr } = opts; ctx.save(); if (asserts.isBox(graph)) { const elements = graph.elements; const cap = elements.length; for(let i = 0; i < cap; i++){ const element = elements[i]; drawGraphIntoCanvas(element, opts); } } if (asserts.isGraph(graph)) { const matrix = graph.matrix.create({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); matrix.transform(graph.x, graph.y, graph.scaleX, graph.scaleY, graph.rotation, graph.skewX, graph.skewY); applyCanvasTransform(ctx, matrix, dpr); graph.render(ctx); } ctx.restore(); } class Schedule extends Box { render; to; event; constructor(to, renderOptions = {}){ super(); this.to = typeof to === 'string' ? document.querySelector(to) : to; if (!this.to) { log.panic('The element to bind is not found.'); } const { width, height } = this.to.getBoundingClientRect(); Object.assign(renderOptions, { width, height }, { devicePixelRatio: window.devicePixelRatio || 1 }); this.event = new Event(); this.render = new Render(this.to, renderOptions); } update() { this.render.clear(this.render.options.width, this.render.options.height); this.execute(this.render, this); const matrix = this.matrix.create({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); applyCanvasTransform(this.render.ctx, matrix, this.render.options.devicePixelRatio); } // execute all graph elements execute(render, graph = this) { drawGraphIntoCanvas(graph, { c: render.canvas, ctx: render.ctx, dpr: render.options.devicePixelRatio }); } } function sortChildrenByKey(data, ...keys) { return data.sort((a, b)=>{ for (const key of keys){ const v = a[key]; const v2 = b[key]; if (perferNumeric(v) && perferNumeric(v2)) { if (v2 > v) { return 1; } if (v2 < v) { return -1; } continue; } // Not numeric, compare as string const comparison = ('' + v).localeCompare('' + v2); if (comparison !== 0) { return comparison; } } return 0; }); } function c2m(data, key, modifier) { if (Array.isArray(data.groups)) { data.groups = sortChildrenByKey(data.groups.map((d)=>c2m(d, key, modifier)), 'weight'); } const obj = { ...data, weight: data[key] }; if (modifier) { Object.assign(obj, modifier(obj)); } return obj; } function flatten(data) { const result = []; for(let i = 0; i < data.length; i++){ const { groups, ...rest } = data[i]; result.push(rest); if (groups) { result.push(...flatten(groups)); } } return result; } function bindParentForModule(modules, parent) { return modules.map((module)=>{ const next = { ...module }; next.parent = parent; if (next.groups && Array.isArray(next.groups)) { next.groups = bindParentForModule(next.groups, next); } return next; }); } function getNodeDepth(node) { let depth = 0; while(node.parent){ node = node.parent; depth++; } return depth; } function visit(data, fn) { if (!data) { return null; } for (const d of data){ if (d.children) { const result = visit(d.children, fn); if (result) { return result; } } const stop = fn(d); if (stop) { return d; } } return null; } function findRelativeNode(p, layoutNodes) { return visit(layoutNodes, (node)=>{ const [x, y, w, h] = node.layout; if (p.x >= x && p.y >= y && p.x < x + w && p.y < y + h) { return true; } }); } function findRelativeNodeById(id, layoutNodes) { return visit(layoutNodes, (node)=>{ if (node.node.id === id) { return true; } }); } function generateStableCombinedNodeId(weight, nodes) { const name = nodes.map((node)=>node.id).sort().join('-'); return Math.abs(hashCode(name)) + '-' + weight; } function processSquarifyData(data, totalArea, minNodeSize, minNodeArea) { if (!data || !data.length) { return []; } const totalWeight = data.reduce((sum, node)=>sum + node.weight, 0); if (totalWeight <= 0) { return []; } const processedNodes = []; const tooSmallNodes = []; data.forEach((node)=>{ const nodeArea = node.weight / totalWeight * totalArea; const estimatedSize = Math.sqrt(nodeArea); if (estimatedSize < minNodeSize || nodeArea < minNodeArea) { tooSmallNodes.push({ ...node }); } else { processedNodes.push({ ...node }); } }); if (tooSmallNodes.length > 0) { const combinedWeight = tooSmallNodes.reduce((sum, node)=>sum + node.weight, 0); if (combinedWeight > 0 && combinedWeight / totalWeight * totalArea >= minNodeArea) { const combinedNode = { id: `combined-node-${generateStableCombinedNodeId(combinedWeight, tooSmallNodes)}`, weight: combinedWeight, isCombinedNode: true, originalNodeCount: tooSmallNodes.length, // @ts-expect-error fixme parent: null, groups: [], originalNodes: tooSmallNodes }; processedNodes.push(combinedNode); } } return processedNodes; } function squarify(data, rect, config, scale = 1) { const result = []; if (!data.length) { return result; } const totalArea = rect.w * rect.h; const containerSize = Math.min(rect.w, rect.h); const scaleFactor = Math.max(0.5, Math.min(1, containerSize / 800)); const baseMinSize = 20; const minRenderableSize = Math.max(8, baseMinSize / Math.sqrt(scale)); const minRenderableArea = minRenderableSize * minRenderableSize; const scaledGap = config.rectGap * scaleFactor; const scaledRadius = config.rectRadius * scaleFactor; const processedData = processSquarifyData(data, totalArea, minRenderableSize, minRenderableArea); if (!processedData.length) { return result; } let workingRect = rect; if (scaledGap > 0) { workingRect = { x: rect.x + scaledGap / 2, y: rect.y + scaledGap / 2, w: Math.max(0, rect.w - scaledGap), h: Math.max(0, rect.h - scaledGap) }; } const worst = (start, end, shortestSide, totalWeight, aspectRatio)=>{ const max = processedData[start].weight * aspectRatio; const min = processedData[end].weight * aspectRatio; return Math.max(shortestSide * shortestSide * max / (totalWeight * totalWeight), totalWeight * totalWeight / (shortestSide * shortestSide * min)); }; const recursion = (start, rect, depth = 0)=>{ const depthFactor = Math.max(0.4, 1 - depth * 0.15); const currentGap = scaledGap * depthFactor; const currentRadius = scaledRadius * depthFactor; while(start < processedData.length){ let totalWeight = 0; for(let i = start; i < processedData.length; i++){ totalWeight += processedData[i].weight; } const shortestSide = Math.min(rect.w, rect.h); const aspectRatio = rect.w * rect.h / totalWeight; let end = start; let areaInRun = 0; let oldWorst = 0; while(end < processedData.length){ const area = processedData[end].weight * aspectRatio || 0; const newWorst = worst(start, end, shortestSide, areaInRun + area, aspectRatio); if (end > start && oldWorst < newWorst) { break; } areaInRun += area; oldWorst = newWorst; end++; } const splited = Math.round(areaInRun / shortestSide); let areaInLayout = 0; const isHorizontalLayout = rect.w >= rect.h; for(let i = start; i < end; i++){ const isFirst = i === start; const isLast = i === end - 1; const children = processedData[i]; const area = children.weight * aspectRatio; const lower = Math.round(shortestSide * areaInLayout / areaInRun); const upper = Math.round(shortestSide * (areaInLayout + area) / areaInRun); let x, y, w, h; if (isHorizontalLayout) { x = rect.x; y = rect.y + lower; w = splited; h = upper - lower; } else { x = rect.x + lower; y = rect.y; w = upper - lower; h = splited; } if (currentGap > 0) { const edgeGap = currentGap / 2; if (!isFirst) { if (isHorizontalLayout) { y += edgeGap; h = Math.max(0, h - edgeGap); } else { x += edgeGap; w = Math.max(0, w - edgeGap); } } if (!isLast) { if (isHorizontalLayout) { h = Math.max(0, h - edgeGap); } else { w = Math.max(0, w - edgeGap); } } } const nodeDepth = getNodeDepth(children) || 1; const { titleAreaHeight } = config; const diff = titleAreaHeight.max / nodeDepth; const titleHeight = diff < titleAreaHeight.min ? titleAreaHeight.min : diff; w = Math.max(1, w); h = Math.max(1, h); let childrenLayout = []; const hasValidChildren = children.groups && children.groups.length > 0; if (hasValidChildren) { const childGapOffset = currentGap > 0 ? currentGap : 0; const childRect = { x: x + childGapOffset, y: y + titleHeight, w: Math.max(0, w - childGapOffset * 2), h: Math.max(0, h - titleHeight - childGapOffset) }; const minChildSize = currentRadius > 0 ? currentRadius * 2 : 1; if (childRect.w >= minChildSize && childRect.h >= minChildSize) { childrenLayout = squarify(children.groups || [], childRect, { ...config, rectGap: currentGap, rectRadius: currentRadius }, scale); } } result.push({ layout: [ x, y, w, h ], node: children, children: childrenLayout, config: { titleAreaHeight: titleHeight, rectGap: currentGap, rectRadius: currentRadius } }); areaInLayout += area; } start = end; const rectGapOffset = currentGap > 0 ? currentGap : 0; if (isHorizontalLayout) { rect.x += splited + rectGapOffset; rect.w = Math.max(0, rect.w - splited - rectGapOffset); } else { rect.y += splited + rectGapOffset; rect.h = Math.max(0, rect.h - splited - rectGapOffset); } } }; recursion(0, workingRect); return result; } function definePlugin(plugin) { return plugin; } class PluginDriver { plugins = new Map(); pluginContext; constructor(component){ this.pluginContext = { resolveModuleById (id) { return findRelativeNodeById(id, component.layoutNodes); }, getPluginMetadata: (pluginName)=>{ return this.getPluginMetadata(pluginName); }, get instance () { return component; } }; } use(plugin) { if (!plugin.name) { logger.error('Plugin name is required'); return; } if (this.plugins.has(plugin.name)) { logger.panic(`Plugin ${plugin.name} is already registered`); } this.plugins.set(plugin.name, plugin); } runHook(hookName, ...args) { this.plugins.forEach((plugin)=>{ const hook = plugin[hookName]; if (hook) { // @ts-expect-error fixme hook.apply(this.pluginContext, args); } }); } cascadeHook(hookName, ...args) { const finalResult = {}; this.plugins.forEach((plugin)=>{ const hook = plugin[hookName]; if (hook) { // @ts-expect-error fixme const hookResult = hook.call(this.pluginContext, ...args); if (hookResult) { Object.assign(finalResult, hookResult); } } }); return finalResult; } getPluginMetadata(pluginName) { const plugin = this.plugins.get(pluginName); return plugin?.meta || null; } } const logger = createLogger('Treemap'); const DEFAULT_RECT_FILL_DESC = { mode: 'rgb', desc: { r: 0, g: 0, b: 0 } }; const DEFAULT_TITLE_AREA_HEIGHT = { min: 30, max: 60 }; const DEFAULT_RECT_GAP = 4; const DEFAULT_RECT_BORDER_RADIUS = 4; const DEFAULT_FONT_SIZE = { max: 70, min: 12 }; const DEFAULT_FONT_FAMILY = 'sans-serif'; const DEFAULT_FONT_COLOR = '#000'; class Component extends Schedule { pluginDriver; data; colorMappings; rectLayer; textLayer; layoutNodes; config; caches; constructor(config, ...args){ super(...args); this.data = []; this.config = config; this.colorMappings = {}; this.pluginDriver = new PluginDriver(this); this.rectLayer = new Box(); this.textLayer = new Box(); this.caches = new DefaultMap(()=>14); this.layoutNodes = []; } clearFontCacheInAABB(aabb) { const affectedModules = this.getModulesInAABB(this.layoutNodes, aabb); for (const module of affectedModules){ this.caches.delete(module.node.id); } } getModulesInAABB(modules, aabb) { const result = []; for (const module of modules){ const [x, y, w, h] = module.layout; const moduleAABB = { x, y, width: w, height: h }; if (this.isAABBIntersecting(moduleAABB, aabb)) { result.push(module); if (module.children && module.children.length > 0) { result.push(...this.getModulesInAABB(module.children, aabb)); } } } return result; } getViewportAABB(matrixE, matrixF) { const { width, height } = this.render.options; const viewportX = -matrixE; const viewportY = -matrixF; const viewportWidth = width; const viewportHeight = height; return { x: viewportX, y: viewportY, width: viewportWidth, height: viewportHeight }; } getAABBUnion(a, b) { const minX = Math.min(a.x, b.x); const minY = Math.min(a.y, b.y); const maxX = Math.max(a.x + a.width, b.x + b.width); const maxY = Math.max(a.y + a.height, b.y + b.height); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } handleTransformCacheInvalidation(oldMatrix, newMatrix) { const oldViewportAABB = this.getViewportAABB(oldMatrix.e, oldMatrix.f); const newViewportAABB = this.getViewportAABB(newMatrix.e, newMatrix.f); const affectedAABB = this.getAABBUnion(oldViewportAABB, newViewportAABB); this.clearFontCacheInAABB(affectedAABB); } isAABBIntersecting(a, b) { return !(a.x + a.width < b.x || b.x + b.width < a.x || a.y + a.height < b.y || b.y + b.height < a.y); } drawBroundRect(node) { const [x, y, w, h] = node.layout; const { rectRadius } = node.config; const effectiveRadius = Math.min(rectRadius, w / 4, h / 4); const fill = this.colorMappings[node.node.id] || DEFAULT_RECT_FILL_DESC; const rect = createRoundBlock(x, y, w, h, { fill, padding: 0, radius: effectiveRadius }); this.rectLayer.add(rect); for (const child of node.children){ this.drawBroundRect(child); } } drawText(node) { if (!node.node.label && !node.node.isCombinedNode) { return; } const [x, y, w, h] = node.layout; const { titleAreaHeight } = node.config; const content = node.node.isCombinedNode ? `+ ${node.node.originalNodeCount} Modules` : node.node.label; const availableHeight = node.children && node.children.length > 0 ? titleAreaHeight - DEFAULT_RECT_GAP * 2 : h - DEFAULT_RECT_GAP * 2; const availableWidth = w - DEFAULT_RECT_GAP * 2; if (availableWidth <= 0 || availableHeight <= 0) { return; } const config = { fontSize: this.config.font?.fontSize || DEFAULT_FONT_SIZE, family: this.config.font?.family || DEFAULT_FONT_FAMILY, color: this.config.font?.color || DEFAULT_FONT_COLOR }; const optimalFontSize = this.caches.getOrInsert(node.node.id, evaluateOptimalFontSize(this.render.ctx, content, config, availableWidth, availableHeight)); const font = `${optimalFontSize}px ${config.family}`; this.render.ctx.font = font; const result = getTextLayout(this.render.ctx, content, availableWidth, availableHeight); if (!result.valid) { return; } const { text } = result; const textX = x + Math.round(w / 2); const textY = y + (node.children && node.children.length > 0 ? Math.round(titleAreaHeight / 2) : Math.round(h / 2)); const textComponent = createTitleText(text, textX, textY, font, config.color); this.textLayer.add(textComponent); for (const child of node.children){ this.drawText(child); } } draw(flush = true, update = true) { // prepare data const { width, height } = this.render.options; if (update) { this.layoutNodes = this.calculateLayoutNodes(this.data, { w: width, h: height, x: 0, y: 0 }); } if (flush) { const result = this.pluginDriver.cascadeHook('onModuleInit', this.layoutNodes); if (result) { this.colorMappings = result.colorMappings || {}; } } for (const node of this.layoutNodes){ this.drawBroundRect(node); } for (const node of this.layoutNodes){ this.drawText(node); } this.add(this.rectLayer, this.textLayer); if (update) { this.update(); } } cleanup() { this.remove(this.rectLayer, this.textLayer); this.rectLayer.destory(); this.textLayer.destory(); } calculateLayoutNodes(data, rect, scale = 1) { const config = { titleAreaHeight: this.config.layout?.titleAreaHeight ?? DEFAULT_TITLE_AREA_HEIGHT, rectRadius: this.config.layout?.rectRadius ?? DEFAULT_RECT_BORDER_RADIUS, rectGap: this.config.layout?.rectGap ?? DEFAULT_RECT_GAP }; const layoutNodes = squarify(data, rect, config, scale); const result = this.pluginDriver.cascadeHook('onLayoutCalculated', layoutNodes, rect, config); if (result && result.layoutNodes?.length) { return result.layoutNodes; } return layoutNodes; } } function evaluateOptimalFontSize(c, text, config, desiredW, desiredH) { desiredW = Math.floor(desiredW); desiredH = Math.floor(desiredH); const { fontSize, family } = config; let min = fontSize.min; let max = fontSize.max; while(max - min >= 1){ const current = min + (max - min) / 2; c.font = `${current}px ${family}`; const textWidth = c.measureText(text).width; const metrics = c.measureText(text); const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; if (textWidth <= desiredW && textHeight <= desiredH) { min = current; } else { max = current; } } return Math.floor(min); } function getTextLayout(c, text, width, height) { const textWidth = c.measureText(text).width; const metrics = c.measureText(text); const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; if (textHeight > height) { return { valid: false, text: '', direction: 'horizontal', width: 0 }; } if (textWidth <= width) { return { valid: true, text, direction: 'horizontal', width: textWidth }; } const ellipsisWidth = c.measureText('...').width; if (width <= ellipsisWidth) { return { valid: false, text: '', direction: 'horizontal', width: 0 }; } let left = 0; let right = text.length; let bestFit = ''; while(left <= right){ const mid = Math.floor((left + right) / 2); const substring = text.substring(0, mid); const subWidth = c.measureText(substring).width; if (subWidth + ellipsisWidth <= width) { bestFit = substring; left = mid + 1; } else { right = mid - 1; } } return bestFit.length > 0 ? { valid: true, text: bestFit + '...', direction: 'horizontal', width } : { valid: true, text: '...', direction: 'horizontal', width: ellipsisWidth }; } // I think those event is enough for user. const DOM_EVENTS = [ 'click', 'mousedown', 'mousemove', 'mouseup', 'mouseover', 'mouseout', 'wheel', 'contextmenu' ]; const STATE_TRANSITION = { IDLE: 'IDLE'}; class StateManager { current; constructor(){ this.current = STATE_TRANSITION.IDLE; } canTransition(to) { switch(this.current){ case 'IDLE': return to === 'PRESSED' || to === 'MOVE' || to === 'SCALING' || to === 'ZOOMING' || to === 'PANNING'; case 'PRESSED': return to === 'DRAGGING' || to === 'IDLE'; case 'DRAGGING': return to === 'IDLE'; case 'MOVE': return to === 'PRESSED' || to === 'IDLE'; case 'SCALING': return to === 'IDLE'; case 'ZOOMING': return to === 'IDLE'; case 'PANNING': return to === 'IDLE'; default: return false; } } transition(to) { const valid = this.canTransition(to); if (valid) { this.current = to; } return valid; } reset() { this.current = STATE_TRANSITION.IDLE; } isInState(state) { return this.current === state; } } function isWheelEvent(metadata) { return metadata.kind === 'wheel'; } function isMouseEvent(metadata) { return [ 'mousedown', 'mouseup', 'mousemove' ].includes(metadata.kind); } function isClickEvent(metadata) { return metadata.kind === 'click'; } function isContextMenuEvent(metadata) { return metadata.kind === 'contextmenu'; } function bindDOMEvent(el, evt, dom) { const handler = (e)=>{ const data = { native: e }; Object.defineProperty(data, 'kind', { value: evt, enumerable: true, configurable: false, writable: false }); // @ts-expect-error safe operation dom.emit(evt, data); }; el.addEventListener(evt, handler); return { evt, handler }; } // We don't consider db click for us library // So the trigger step follows: // mousedown => mouseup => click // For menu click (downstream demand) class DOMEvent extends Event { domEvents; el; currentModule; component; matrix; stateManager; constructor(component){ super(); this.component = component; this.el = component.render.canvas; this.matrix = new Matrix2D(); this.currentModule = null; this.stateManager = new StateManager(); this.domEvents = DOM_EVENTS.map((evt)=>bindDOMEvent(this.el, evt, this)); DOM_EVENTS.forEach((evt)=>{ this.on(evt, (e)=>{ this.dispatch(evt, e); }); }); } destory() { if (this.el) { this.domEvents.forEach(({ evt, handler })=>this.el?.removeEventListener(evt, handler)); this.domEvents = []; for(const evt in this.eventCollections){ this.off(evt); } this.matrix.crea