squarified
Version:
squarified tree map
671 lines (662 loc) • 26 kB
JavaScript
import { h as definePlugin, B as smoothFrame, r as createRoundBlock, z as stackMatrixTransform, S as Schedule, H as DEFAULT_MATRIX_LOC, F as isScrollWheelOrRightButtonOnMouseupAndDown, A as stackMatrixTransformWithGraphAndLayer, j as isContextMenuEvent, o as hashCode, P as PI_2, n as isWheelEvent, m as mixin } from './dom-event-DrYYfglv.mjs';
// Currently, etoile is an internal module, so we won't need too much easing functions.
// And the animation logic is implemented by user code.
const easing = {
linear: (k)=>k,
quadraticIn: (k)=>k * k,
quadraticOut: (k)=>k * (2 - k),
quadraticInOut: (k)=>{
if ((k *= 2) < 1) {
return 0.5 * k * k;
}
return -0.5 * (--k * (k - 2) - 1);
},
cubicIn: (k)=>k * k * k,
cubicOut: (k)=>{
if ((k *= 2) < 1) {
return 0.5 * k * k * k;
}
return 0.5 * ((k -= 2) * k * k + 2);
},
cubicInOut: (k)=>{
if ((k *= 2) < 1) {
return 0.5 * k * k * k;
}
return 0.5 * ((k -= 2) * k * k + 2);
}
};
class Highlight extends Schedule {
reset() {
this.destory();
this.update();
}
get canvas() {
return this.render.canvas;
}
setZIndexForHighlight(zIndex = '-1') {
this.canvas.style.zIndex = zIndex;
}
init() {
this.setZIndexForHighlight();
this.canvas.style.position = 'absolute';
this.canvas.style.pointerEvents = 'none';
}
}
const ANIMATION_DURATION = 300;
const HIGH_LIGHT_OPACITY = 0.3;
const fill = {
desc: {
r: 255,
g: 255,
b: 255
},
mode: 'rgb'
};
const presetHighlightPlugin = definePlugin({
name: 'treemap:preset-highlight',
onLoad () {
const meta = this.getPluginMetadata('treemap:preset-highlight');
if (!meta) {
return;
}
if (!meta.highlight) {
meta.highlight = new Highlight(this.instance.to);
}
},
onDOMEventTriggered (name, _, module, { stateManager: state, matrix }) {
if (name === 'mousemove') {
if (state.canTransition('MOVE')) {
const meta = this.getPluginMetadata('treemap:preset-highlight');
if (!module) {
meta.highlight?.reset();
meta.highlight?.update();
meta.highlight?.setZIndexForHighlight();
meta.highlightSeq = (meta.highlightSeq ?? 0) + 1;
return;
}
const [x, y, w, h] = module.layout;
const effectiveRadius = Math.min(module.config.rectRadius, w / 4, h / 4);
meta.highlightSeq = (meta.highlightSeq ?? 0) + 1;
const thisSeq = meta.highlightSeq;
smoothFrame((_, cleanup)=>{
if (meta.highlightSeq !== thisSeq) {
meta.highlight?.setZIndexForHighlight();
cleanup();
return;
}
cleanup();
meta.highlight?.reset();
const mask = createRoundBlock(x, y, w, h, {
fill,
opacity: HIGH_LIGHT_OPACITY,
radius: effectiveRadius,
padding: 0
});
meta.highlight?.add(mask);
meta.highlight?.setZIndexForHighlight('1');
stackMatrixTransform(mask, matrix.e, matrix.f, 1);
meta.highlight?.update();
}, {
duration: ANIMATION_DURATION
});
}
}
},
onResize () {
const meta = this.getPluginMetadata('treemap:preset-highlight');
if (!meta) {
return;
}
meta.highlight?.render.initOptions({
...this.instance.render.options
});
meta.highlight?.reset();
meta.highlight?.init();
},
onDispose () {
const meta = this.getPluginMetadata('treemap:preset-highlight');
if (meta && meta.highlight) {
meta.highlight.destory();
meta.highlight = null;
}
},
meta: {
highlight: null,
highlightSeq: 0
}
});
const presetDragElementPlugin = definePlugin({
name: 'treemap:preset-drag-element',
onDOMEventTriggered (name, event, module, domEvent) {
const { stateManager: state, matrix, component } = domEvent;
switch(name){
case 'mousemove':
{
if (state.isInState('DRAGGING')) {
domEvent.silent('click');
} else {
domEvent.active('click');
}
const meta = getDragOptions.call(this);
if (!meta) {
return;
}
if (meta.dragOptions.x === 0 && meta.dragOptions.y === 0) {
state.transition('IDLE');
return;
}
state.transition('DRAGGING');
if (state.isInState('DRAGGING')) {
const highlight = getHighlightInstance.call(this);
smoothFrame((_, cleanup)=>{
cleanup();
const { offsetX, offsetY } = event.native;
const drawX = offsetX - meta.dragOptions.x;
const drawY = offsetY - meta.dragOptions.y;
const lastX = meta.dragOptions.x;
const lastY = meta.dragOptions.y;
if (highlight?.highlight) {
highlight.highlight.reset();
highlight.highlight.setZIndexForHighlight();
}
matrix.translation(drawX, drawY);
meta.dragOptions.x = offsetX;
meta.dragOptions.y = offsetY;
meta.dragOptions.lastX = lastX;
meta.dragOptions.lastY = lastY;
component.cleanup();
component.draw(false, false);
stackMatrixTransformWithGraphAndLayer(component.elements, matrix.e, matrix.f, 1);
component.update();
return true;
}, {
duration: ANIMATION_DURATION,
deps: [
()=>state.isInState('IDLE')
]
});
}
break;
}
case 'mouseup':
{
if (state.isInState('PRESSED')) {
const meta = getDragOptions.call(this);
if (meta && meta.dragOptions) {
if (meta.dragOptions.x === meta.dragOptions.lastX && meta.dragOptions.y === meta.dragOptions.lastY) {
state.transition('IDLE');
return;
}
}
}
if (state.isInState('DRAGGING') && state.canTransition('IDLE')) {
const highlight = getHighlightInstance.call(this);
if (highlight && highlight.highlight) {
highlight.highlight.reset();
highlight.highlight.setZIndexForHighlight();
}
const meta = getDragOptions.call(this);
if (meta && meta.dragOptions) {
meta.dragOptions.x = 0;
meta.dragOptions.y = 0;
meta.dragOptions.lastX = 0;
meta.dragOptions.lastY = 0;
state.transition('IDLE');
}
}
break;
}
case 'mousedown':
{
if (isScrollWheelOrRightButtonOnMouseupAndDown(event.native)) {
return;
}
const meta = getDragOptions.call(this);
if (!meta) {
return;
}
meta.dragOptions.x = event.native.offsetX;
meta.dragOptions.y = event.native.offsetY;
meta.dragOptions.lastX = event.native.offsetX;
meta.dragOptions.lastY = event.native.offsetY;
state.transition('PRESSED');
break;
}
}
},
meta: {
dragOptions: {
x: 0,
y: 0,
lastX: 0,
lastY: 0
}
},
onResize ({ matrix, stateManager: state }) {
matrix.create(DEFAULT_MATRIX_LOC);
state.reset();
}
});
function getHighlightInstance() {
return this.getPluginMetadata('treemap:preset-highlight');
}
function getDragOptions() {
const meta = this.getPluginMetadata('treemap:preset-drag-element');
return meta;
}
function presetMenuPlugin(options) {
let menu = null;
let domEvent = null;
const handleMenuClick = (e)=>{
if (!domEvent) {
return;
}
if (!menu) {
return;
}
const target = e.target;
if (target.parentNode) {
const parent = target.parentNode;
const action = parent.getAttribute('data-action');
if (!action) {
return;
}
if (options?.onClick) {
options.onClick(action, domEvent.findRelativeNode({
native: e,
kind: undefined
}));
}
}
menu.style.display = 'none';
};
return definePlugin({
name: 'treemap:preset-menu',
onDOMEventTriggered (_, event, __, DOMEvent) {
if (isContextMenuEvent(event)) {
event.native.stopPropagation();
event.native.preventDefault();
if (!menu) {
menu = document.createElement('div');
domEvent = DOMEvent;
Object.assign(menu.style, {
backgroundColor: '#fff',
...options?.style,
position: 'absolute',
zIndex: '9999'
});
menu.addEventListener('click', handleMenuClick);
if (menu && options?.render) {
const result = options.render(menu);
menu.innerHTML = result.map((item)=>{
return `<div data-action='${item.action}'>${item.html}</div>`;
}).join('');
}
document.body.append(menu);
}
menu.style.left = event.native.clientX + 'px';
menu.style.top = event.native.clientY + 'px';
menu.style.display = 'initial';
}
},
onDispose () {
if (!menu) {
return;
}
menu.removeEventListener('click', handleMenuClick);
menu = null;
domEvent = null;
}
});
}
const presetColorPlugin = definePlugin({
name: 'treemap:preset-color',
onModuleInit (modules) {
const colorMappings = {};
for(let i = 0; i < modules.length; i++){
const module = modules[i];
assignColorMappings(colorMappings, module, Math.abs(hashCode(module.node.id)) % PI_2, 0);
}
return {
colorMappings
};
}
});
function assignColorMappings(colorMappings, module, ancestorHue, depth) {
const hueOffset = Math.abs(hashCode(module.node.id)) % 60 - 30;
const hue = (ancestorHue + hueOffset) % 360;
const saturation = Math.max(75 - depth * 5, 40);
const baseLightness = 55 - depth * 3;
const color = adjustColorToComfortableForHumanEye(hue, saturation, baseLightness);
colorMappings[module.node.id] = color;
if (module.node.isCombinedNode && module.node.originalNodes) {
for (const combined of module.node.originalNodes){
colorMappings[combined.id] = color;
}
}
if (module.children && module.children.length) {
const childCount = module.children.length;
for(let i = 0; i < childCount; i++){
const childHueOffset = 40 * i / childCount;
const childHue = (hue + childHueOffset) % 360;
assignColorMappings(colorMappings, module.children[i], childHue, depth + 1);
}
}
}
function adjustColorToComfortableForHumanEye(hue, saturation, lightness) {
hue = (hue % 360 + 360) % 360;
saturation = Math.min(Math.max(saturation, 40), 85);
lightness = Math.min(Math.max(lightness, 35), 75);
if (hue >= 60 && hue <= 180) {
saturation = Math.max(saturation - 10, 40);
lightness = Math.min(lightness + 5, 75);
} else if (hue >= 200 && hue <= 280) {
lightness = Math.min(lightness + 8, 75);
} else if (hue >= 0 && hue <= 30) {
saturation = Math.max(saturation - 5, 40);
}
return {
mode: 'hsl',
desc: {
h: hue,
s: saturation,
l: lightness
}
};
}
function presetScalePlugin(options) {
return definePlugin({
name: 'treemap:preset-scale',
onDOMEventTriggered (_, event, module, evt) {
if (isWheelEvent(event)) {
onWheel(this, event, evt);
}
},
meta: {
scaleOptions: {
scale: 1,
minScale: options?.min || 0.1,
maxScale: options?.max || Infinity,
scaleFactor: 0.05
},
gestureState: {
isTrackingGesture: false,
lastEventTime: 0,
eventCount: 0,
totalDeltaY: 0,
totalDeltaX: 0,
consecutivePinchEvents: 0,
gestureType: 'unknown',
lockGestureType: false
}
},
onResize ({ matrix, stateManager: state }) {
const meta = getScaleOptions.call(this);
if (meta) {
meta.scaleOptions.scale = 1;
}
matrix.create(DEFAULT_MATRIX_LOC);
state.reset();
}
});
}
function getScaleOptions() {
const meta = this.getPluginMetadata('treemap:preset-scale');
return meta;
}
function determineGestureType(event, gestureState) {
const now = Date.now();
const timeDiff = now - gestureState.lastEventTime;
if (timeDiff > 150) {
Object.assign(gestureState, {
isTrackingGesture: false,
lastEventTime: now,
eventCount: 1,
totalDeltaY: Math.abs(event.deltaY),
totalDeltaX: Math.abs(event.deltaX),
consecutivePinchEvents: 0,
gestureType: 'unknown',
lockGestureType: false
});
} else {
gestureState.eventCount++;
gestureState.totalDeltaY += Math.abs(event.deltaY);
gestureState.totalDeltaX += Math.abs(event.deltaX);
gestureState.lastEventTime = now;
}
if (event.ctrlKey) {
gestureState.gestureType = 'zoom';
gestureState.lockGestureType = true;
return 'zoom';
}
// windows/macos mouse wheel
// Usually the dettaY is large and deltaX maybe 0 or small number.
const isMouseWheel = Math.abs(event.deltaX) >= 100 && Math.abs(event.deltaX) <= 10 || Math.abs(event.deltaY) > 50 && Math.abs(event.deltaX) < Math.abs(event.deltaY) * 0.1;
if (isMouseWheel) {
gestureState.gestureType = 'zoom';
gestureState.lockGestureType = true;
return 'zoom';
}
if (gestureState.lockGestureType && gestureState.gestureType !== 'unknown') {
return gestureState.gestureType;
}
// Magic Trackpad or Precision Touchpad
if (gestureState.eventCount >= 3) {
const avgDeltaY = gestureState.totalDeltaY / gestureState.eventCount;
const avgDeltaX = gestureState.totalDeltaX / gestureState.eventCount;
const ratio = avgDeltaX / (avgDeltaY + 0.1);
const isZoomGesture = avgDeltaY > 8 && ratio < 0.3 && Math.abs(event.deltaY) > 5;
if (isZoomGesture) {
gestureState.gestureType = 'zoom';
gestureState.lockGestureType = true;
return 'zoom';
} else {
gestureState.gestureType = 'pan';
gestureState.lockGestureType = true;
return 'pan';
}
}
return 'pan';
}
function onWheel(pluginContext, event, domEvent) {
event.native.preventDefault();
const meta = getScaleOptions.call(pluginContext);
if (!meta) {
return;
}
const gestureType = determineGestureType(event.native, meta.gestureState);
if (gestureType === 'zoom') {
handleZoom(pluginContext, event, domEvent);
} else {
handlePan(pluginContext, event, domEvent);
}
}
function updateViewport(pluginContext, { stateManager: state, component, matrix }, useAnimation = false) {
const highlight = getHighlightInstance.apply(pluginContext);
const doUpdate = ()=>{
if (highlight && highlight.highlight) {
highlight.highlight.reset();
highlight.highlight.setZIndexForHighlight();
}
if (highlight) {
highlight.highlightSeq = (highlight.highlightSeq ?? 0) + 1;
}
component.cleanup();
const { width, height } = component.render.options;
component.layoutNodes = component.calculateLayoutNodes(component.data, {
w: width * matrix.a,
h: height * matrix.d,
x: 0,
y: 0
}, 1);
component.draw(true, false);
stackMatrixTransformWithGraphAndLayer(component.elements, matrix.e, matrix.f, 1);
component.update();
if (state.canTransition('IDLE')) {
state.transition('IDLE');
}
};
if (useAnimation) {
smoothFrame((_, cleanup)=>{
cleanup();
doUpdate();
return true;
}, {
duration: ANIMATION_DURATION
});
} else {
doUpdate();
}
}
function handleZoom(pluginContext, event, domEvent) {
const { stateManager: state, matrix, component } = domEvent;
const meta = getScaleOptions.call(pluginContext);
if (!meta) {
return;
}
const { scale, minScale, maxScale, scaleFactor } = meta.scaleOptions;
const oldMatrix = {
e: matrix.e,
f: matrix.f
};
const dynamicScaleFactor = Math.max(scaleFactor, scale * 0.1);
const delta = event.native.deltaY < 0 ? dynamicScaleFactor : -dynamicScaleFactor;
const newScale = Math.max(minScale, Math.min(maxScale, scale + delta));
if (newScale === scale) {
return;
}
state.transition('SCALING');
const mouseX = event.native.offsetX;
const mouseY = event.native.offsetY;
const scaleDiff = newScale / scale;
meta.scaleOptions.scale = newScale;
matrix.scale(scaleDiff, scaleDiff);
matrix.e = mouseX - (mouseX - matrix.e) * scaleDiff;
matrix.f = mouseY - (mouseY - matrix.f) * scaleDiff;
const newMatrix = {
e: matrix.e,
f: matrix.f
};
component.handleTransformCacheInvalidation(oldMatrix, newMatrix);
updateViewport(pluginContext, domEvent, false);
}
function handlePan(pluginContext, event, domEvent) {
const { stateManager: state, matrix } = domEvent;
const panSpeed = 0.8;
const deltaX = event.native.deltaX * panSpeed;
const deltaY = event.native.deltaY * panSpeed;
state.transition('PANNING');
matrix.e -= deltaX;
matrix.f -= deltaY;
updateViewport(pluginContext, domEvent, true);
}
const MAX_SCALE_MULTIPLIER = 2.0;
const ZOOM_PADDING_RATIO = 0.85;
const presetZoomablePlugin = definePlugin({
name: 'treemap:preset-zoomable',
onLoad (treemap, { stateManager: state, matrix }) {
return mixin(treemap, [
{
name: 'zoom',
fn: ()=>(id)=>{
const meta = this.getPluginMetadata('treemap:preset-zoomable');
if (!meta || state.isInState('ZOOMING')) {
return;
}
const targetModule = this.resolveModuleById(id);
if (!targetModule) {
return;
}
const oldMatrix = {
e: matrix.e,
f: matrix.f,
a: matrix.a
};
meta.previousMatrixState = {
e: matrix.e,
f: matrix.f,
a: matrix.a,
d: matrix.d
};
const component = this.instance;
state.transition('ZOOMING');
const [nodeX, nodeY, nodeW, nodeH] = targetModule.layout;
const { width, height } = component.render.options;
const currentScale = matrix.a;
// To prevent unlimited scale factor growth.
const scaleX = width * ZOOM_PADDING_RATIO / nodeW;
const scaleY = height * ZOOM_PADDING_RATIO / nodeH;
const idleScale = Math.min(scaleX, scaleY);
const maxAllowedScale = currentScale * MAX_SCALE_MULTIPLIER;
const targetScale = Math.max(currentScale, Math.min(idleScale, maxAllowedScale));
// Real world args
const viewportCenterX = width / 2;
const viewportCenterY = height / 2;
const originalNodeCenterX = (nodeX + nodeW / 2) / currentScale;
const originalNodeCenterY = (nodeY + nodeH / 2) / currentScale;
const targetE = viewportCenterX - originalNodeCenterX * targetScale;
const targetF = viewportCenterY - originalNodeCenterY * targetScale;
const scaleMeta = getScaleOptions.call(this);
if (scaleMeta) {
scaleMeta.scaleOptions.scale = targetScale;
}
const highlight = getHighlightInstance.call(this);
const dragMeta = getDragOptions.call(this);
if (dragMeta) {
Object.assign(dragMeta.dragOptions, {
x: 0,
y: 0,
lastX: 0,
lastY: 0
});
}
const startMatrix = {
e: matrix.e,
f: matrix.f,
a: matrix.a,
d: matrix.d
};
const finalMatrix = {
e: targetE,
f: targetF
};
component.handleTransformCacheInvalidation(oldMatrix, finalMatrix);
smoothFrame((progress)=>{
const easedProgress = easing.cubicInOut(progress);
matrix.create(DEFAULT_MATRIX_LOC);
matrix.e = startMatrix.e + (targetE - startMatrix.e) * easedProgress;
matrix.f = startMatrix.f + (targetF - startMatrix.f) * easedProgress;
matrix.a = startMatrix.a + (targetScale - startMatrix.a) * easedProgress;
matrix.d = startMatrix.d + (targetScale - startMatrix.d) * easedProgress;
if (highlight?.highlight) {
highlight.highlight.reset();
highlight.highlight.setZIndexForHighlight();
}
if (highlight) {
highlight.highlightSeq = (highlight.highlightSeq ?? 0) + 1;
}
component.cleanup();
component.layoutNodes = component.calculateLayoutNodes(component.data, {
w: width * matrix.a,
h: height * matrix.d,
x: 0,
y: 0
}, 1);
component.draw(true, false);
stackMatrixTransformWithGraphAndLayer(component.elements, matrix.e, matrix.f, 1);
component.update();
}, {
duration: ANIMATION_DURATION,
onStop: ()=>{
state.reset();
}
});
}
}
]);
},
meta: {
isZooming: false
}
});
export { presetColorPlugin, presetDragElementPlugin, presetHighlightPlugin, presetMenuPlugin, presetScalePlugin, presetZoomablePlugin };