squarified
Version:
squarified tree map
1,690 lines (1,671 loc) • 51.2 kB
JavaScript
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