diff --git a/src/etoile/etoile.ts b/src/etoile/etoile.ts index cde3d2a..b171a55 100644 --- a/src/etoile/etoile.ts +++ b/src/etoile/etoile.ts @@ -6,13 +6,9 @@ function traverse(graphs: Display[], handler: (graph: S) => void) { const len = graphs.length for (let i = 0; i < len; i++) { const graph = graphs[i] - if (asserts.isLayer(graph) && graph.__refresh__) { - handler(graph) - continue - } if (asserts.isGraph(graph)) { handler(graph) - } else if (asserts.isBox(graph) || asserts.isLayer(graph)) { + } else if (asserts.isBox(graph)) { traverse(graph.elements, handler) } } diff --git a/src/etoile/graph/display.ts b/src/etoile/graph/display.ts index 7dc5c03..b02e596 100644 --- a/src/etoile/graph/display.ts +++ b/src/etoile/graph/display.ts @@ -11,14 +11,10 @@ const SELF_ID = { export const enum DisplayType { Graph = 'Graph', - Box = 'Box', - - Rect = 'Rect', - Text = 'Text', - - Layer = 'Layer' + RoundRect = 'RoundRect', + Bitmap = 'Bitmap' } export abstract class Display { @@ -69,10 +65,16 @@ export interface InstructionAssignMappings { textBaseline: (arg: CanvasTextBaseline) => void } -export interface InstructionWithFunctionCall { +export interface InstructionWithFunctionCall extends CanvasDrawImage { fillRect: (x: number, y: number, w: number, h: number) => void strokeRect: (x: number, y: number, w: number, h: number) => void fillText: (text: string, x: number, y: number, maxWidth?: number) => void + beginPath: () => void + moveTo: (x: number, y: number) => void + arcTo: (x1: number, y1: number, x2: number, y2: number, radius: number) => void + closePath: () => void + fill: () => void + stroke: () => void } type Mod< @@ -128,6 +130,29 @@ function createInstruction() { }, 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 }) + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + drawImage(this: Instruction, ...args: any[]) { + // @ts-expect-error safe + this.mods.push({ mod: ['drawImage', args], type: CALL_MAPPINGS_MODE }) } } } @@ -142,6 +167,7 @@ export abstract class S extends Display { rotation: number skewX: number skewY: number + constructor(options: Partial = {}) { super() this.width = options.width || 0 @@ -156,6 +182,8 @@ export abstract class S extends Display { } } +// For performance. we need impl AABB Check for render. + export abstract class Graph extends S { instruction: ReturnType __options__: Partial diff --git a/src/etoile/graph/image.ts b/src/etoile/graph/image.ts new file mode 100644 index 0000000..a1121d6 --- /dev/null +++ b/src/etoile/graph/image.ts @@ -0,0 +1,36 @@ +import { DisplayType, Graph } from './display' +import type { GraphOptions, GraphStyleSheet } from './display' + +export interface BitmapOptions extends Omit { + style: Partial< + GraphStyleSheet & { + font: string, + textAlign: CanvasTextAlign, + baseline: CanvasTextBaseline, + lineWidth: number, + fill: string + } + > + bitmap: HTMLCanvasElement +} + +export class Bitmap extends Graph { + bitmap: HTMLCanvasElement | null + style: Required + constructor(options: Partial = {}) { + super(options) + this.bitmap = options.bitmap || null + this.style = (options.style || Object.create(null)) as Required + } + create() { + if (this.bitmap) { + this.instruction.drawImage(this.bitmap, 0, 0) + } + } + clone() { + return new Bitmap({ ...this.style, ...this.__options__ }) + } + get __shape__() { + return DisplayType.Bitmap + } +} diff --git a/src/etoile/graph/index.ts b/src/etoile/graph/index.ts index d17ad0a..8a2e48c 100644 --- a/src/etoile/graph/index.ts +++ b/src/etoile/graph/index.ts @@ -1,5 +1,5 @@ export { Box } from './box' -export { Layer } from './layer' -export { Rect } from './rect' +export { Bitmap } from './image' +export { RoundRect } from './rect' export { Text } from './text' export * from './types' diff --git a/src/etoile/graph/layer.ts b/src/etoile/graph/layer.ts deleted file mode 100644 index 9a7fc7c..0000000 --- a/src/etoile/graph/layer.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { applyCanvasTransform } from '../../shared' -import { Canvas, writeBoundingRectForCanvas } from '../schedule/render' -import type { RenderViewportOptions } from '../schedule/render' -import { C } from './box' -import { DisplayType, S } from './display' -import type { LocOptions } from './display' - -export class Layer extends C implements S { - private c: Canvas - __refresh__: boolean - options: RenderViewportOptions - width: number - height: number - x: number - y: number - scaleX: number - scaleY: number - rotation: number - skewX: number - skewY: number - - constructor(options: Partial = {}) { - super() - this.c = new Canvas({ width: 0, height: 0, devicePixelRatio: 1 }) - this.__refresh__ = false - this.options = Object.create(null) as RenderViewportOptions - 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 - } - - get __instanceOf__(): DisplayType.Layer { - return DisplayType.Layer - } - - setCanvasOptions(options: Partial = {}) { - Object.assign(this.options, options) - writeBoundingRectForCanvas(this.c.c.canvas, options.width || 0, options.height || 0, options.devicePixelRatio || 1) - } - - cleanCacheSnapshot() { - const dpr = this.options.devicePixelRatio || 1 - const matrix = this.matrix.create({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) - this.ctx.clearRect(0, 0, this.options.width, this.options.height) - return { dpr, matrix } - } - - setCacheSnapshot(c: HTMLCanvasElement) { - const { matrix, dpr } = this.cleanCacheSnapshot() - matrix.transform(this.x, this.y, this.scaleX, this.scaleY, this.rotation, this.skewX, this.skewY) - applyCanvasTransform(this.ctx, matrix, dpr) - this.ctx.drawImage(c, 0, 0, this.options.width / dpr, this.options.height / dpr) - this.__refresh__ = true - } - - initLoc(options: Partial = {}) { - 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 - } - - draw(ctx: CanvasRenderingContext2D) { - const matrix = this.matrix.create({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) - matrix.transform(this.x, this.y, this.scaleX, this.scaleY, this.rotation, this.skewX, this.skewY) - applyCanvasTransform(ctx, matrix, this.options.devicePixelRatio || 1) - ctx.drawImage(this.canvas, 0, 0) - } - - get canvas() { - return this.c.c.canvas - } - - get ctx() { - return this.c.c.ctx - } -} diff --git a/src/etoile/graph/rect.ts b/src/etoile/graph/rect.ts index 02cbf08..c52d741 100644 --- a/src/etoile/graph/rect.ts +++ b/src/etoile/graph/rect.ts @@ -3,35 +3,54 @@ import type { ColorDecoratorResult } from '../native/runtime' import { DisplayType, Graph } from './display' import type { GraphOptions, GraphStyleSheet } from './display' -export type RectStyleOptions = GraphStyleSheet & { fill: ColorDecoratorResult } +export type RectStyleOptions = GraphStyleSheet & { fill: ColorDecoratorResult, padding?: number } export type RectOptions = GraphOptions & { style: Partial } -export class Rect extends Graph { - style: Required - constructor(options: Partial = {}) { + +export type RoundRectStyleOptions = RectStyleOptions & { radius: number } + +export type RoundRectOptions = RectOptions & { style: Partial } + +export class RoundRect extends Graph { + style: Required + constructor(options: Partial = {}) { super(options) - this.style = (options.style || Object.create(null)) as Required + this.style = (options.style || Object.create(null)) as Required } get __shape__() { - return DisplayType.Rect + 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.fillRect(0, 0, this.width, this.height) + this.instruction.fill() } if (this.style.stroke) { - this.instruction.strokeStyle(this.style.stroke) if (typeof this.style.lineWidth === 'number') { this.instruction.lineWidth(this.style.lineWidth) } - this.instruction.strokeRect(0, 0, this.width, this.height) + this.instruction.strokeStyle(this.style.stroke) + this.instruction.stroke() } } clone() { - return new Rect({ ...this.style, ...this.__options__ }) + return new RoundRect({ ...this.style, ...this.__options__ }) } } diff --git a/src/etoile/graph/types.ts b/src/etoile/graph/types.ts index 8492ea8..9f6bc6a 100644 --- a/src/etoile/graph/types.ts +++ b/src/etoile/graph/types.ts @@ -1,7 +1,7 @@ import { Box } from './box' import { Display, DisplayType, Graph } from './display' -import { Layer } from './layer' -import { Rect } from './rect' +import { Bitmap } from './image' +import { RoundRect } from './rect' import { Text } from './text' export function isGraph(display: Display): display is Graph { @@ -12,22 +12,22 @@ export function isBox(display: Display): display is Box { return display.__instanceOf__ === DisplayType.Box } -export function isRect(display: Display): display is Rect { - return isGraph(display) && display.__shape__ === DisplayType.Rect +export function isRoundRect(display: Display): display is RoundRect { + return isGraph(display) && display.__shape__ === DisplayType.RoundRect } export function isText(display: Display): display is Text { return isGraph(display) && display.__shape__ === DisplayType.Text } -export function isLayer(display: Display): display is Layer { - return display.__instanceOf__ === DisplayType.Layer +export function isBitmap(display: Display): display is Bitmap { + return isGraph(display) && display.__shape__ === DisplayType.Bitmap } export const asserts = { isGraph, isBox, - isRect, isText, - isLayer + isRoundRect, + isBitmap } diff --git a/src/etoile/native/dom.ts b/src/etoile/native/dom.ts new file mode 100644 index 0000000..ccc3530 --- /dev/null +++ b/src/etoile/native/dom.ts @@ -0,0 +1,119 @@ +import { raf } from '../../shared' +import { Event } from './event' +import type { BindThisParameter } from './event' +import { Matrix2D } from './matrix' + +// primitive types +export const DOM_EVENTS = ['click', 'mousedown', 'mousemove', 'mouseup', 'mouseover', 'mouseout', 'wheel'] as const + +export type DOMEventType = typeof DOM_EVENTS[number] + +export interface DOMLoc { + x: number + y: number +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface DOMEventMetadata { + native: HTMLElementEventMap[T] + loc: DOMLoc +} + +export type DOMEventCallback = (metadata: DOMEventMetadata) => void + +export type DOMEventDefinition = { + [K in DOMEventType]: BindThisParameter, API> +} + +export function getOffset(el: HTMLElement) { + 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: HTMLElement | null = el; elt; elt = el.offsetParent as HTMLElement | null) { + 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) + ] +} + +export function captureBoxXY(c: HTMLElement, evt: unknown, a: number, d: number, translateX: number, translateY: number) { + 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 } +} + +export interface EffectScopeContext { + animationFrameID: number | null +} + +function createEffectRun(c: EffectScopeContext) { + return (fn: () => boolean | void) => { + const effect = () => { + const done = fn() + if (!done) { + c.animationFrameID = raf(effect) + } + } + if (!c.animationFrameID) { + c.animationFrameID = raf(effect) + } + } +} + +function createEffectStop(c: EffectScopeContext) { + return () => { + if (c.animationFrameID) { + window.cancelAnimationFrame(c.animationFrameID) + c.animationFrameID = null + } + } +} + +// Fill frame +export function createEffectScope() { + const c: EffectScopeContext = { + animationFrameID: null + } + + const run = createEffectRun(c) + const stop = createEffectStop(c) + + return { run, stop } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function bindDOMEvent(el: HTMLElement, evt: DOMEventType | (string & {}), dom: DOMEvent) { + const handler = (e: unknown) => { + const { x, y } = captureBoxXY(el, e, dom.matrix.a, dom.matrix.d, dom.matrix.e, dom.matrix.f) + // @ts-expect-error safe + dom.emit(evt, { native: e, loc: { x, y } }) + } + el.addEventListener(evt, handler) + return handler +} + +export class DOMEvent extends Event> { + el: HTMLElement | null + events: Array> + matrix: Matrix2D + constructor(el: HTMLElement) { + super() + this.el = el + this.matrix = new Matrix2D() + this.events = DOM_EVENTS.map((evt) => bindDOMEvent(this.el!, evt, this)) + } +} diff --git a/src/etoile/native/event.ts b/src/etoile/native/event.ts index 6f863ee..5330701 100644 --- a/src/etoile/native/event.ts +++ b/src/etoile/native/event.ts @@ -10,6 +10,7 @@ export interface EventCollectionData ctx: C + silent: boolean } export type EventCollections = Record< @@ -32,7 +33,8 @@ export class Event> { name: evt, handler, - ctx: c || this + ctx: c || this, + silent: false } this.eventCollections[evt].push(data) } @@ -47,11 +49,34 @@ export class Event) { + if (!(evt in this.eventCollections)) { + return + } + this.eventCollections[evt].forEach((d) => { + if (!handler || d.handler === handler) { + d.silent = true + } + }) + } + + active(evt: keyof EvtDefinition, handler?: BindThisParameter) { + if (!(evt in this.eventCollections)) { + return + } + this.eventCollections[evt].forEach((d) => { + if (!handler || d.handler === handler) { + d.silent = false + } + }) + } + emit(evt: keyof EvtDefinition, ...args: Parameters) { 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) }) } diff --git a/src/etoile/native/magic-trackpad.ts b/src/etoile/native/magic-trackpad.ts new file mode 100644 index 0000000..6b19a8e --- /dev/null +++ b/src/etoile/native/magic-trackpad.ts @@ -0,0 +1,45 @@ +// Note: I won't decide to support mobile devices. +// So don't create any reporting issues about mobile devices. + +import { createEffectScope } from './dom' + +// gesturechange and gestureend is specific for Safari +// So we only implement wheel event +// If you feel malicious on Safari (I won't fix it) + +export interface WheelGesture { + original: { x: number, y: number } + scale: number + translation: { x: number, y: number } +} + +export interface GestureMetadata { + native: WheelEvent + isPanGesture: boolean + data: WheelGesture +} + +export type Ongesturestar = (metadata: GestureMetadata) => void + +export type Ongesturemove = (metadata: GestureMetadata) => void + +export type Ongestureend = (metadata: GestureMetadata) => void + +export interface MagicTrackpadContext { + ongesturestart: Ongesturestar + ongesturemove: Ongesturemove + ongestureend: Ongestureend +} + +// Notte: this only work at wheel event. + +export function useMagicTrackPad(event: WheelEvent) { + if (event.cancelable !== false) { + event.preventDefault() + } + + const isPanGesture = !event.ctrlKey + + // + createEffectScope() +} diff --git a/src/etoile/native/matrix.ts b/src/etoile/native/matrix.ts index 2f539a2..168c5d2 100644 --- a/src/etoile/native/matrix.ts +++ b/src/etoile/native/matrix.ts @@ -42,13 +42,13 @@ export class Matrix2D implements MatrixLoc { return this } - private translation(x: number, y: number) { + translation(x: number, y: number) { this.e += x this.f += y return this } - private scale(a: number, d: number) { + scale(a: number, d: number) { this.a *= a this.d *= d return this diff --git a/src/etoile/schedule/index.ts b/src/etoile/schedule/index.ts index ef8015f..05f480e 100644 --- a/src/etoile/schedule/index.ts +++ b/src/etoile/schedule/index.ts @@ -8,7 +8,7 @@ import { Render } from './render' import type { RenderViewportOptions } from './render' -export type ApplyTo = string | Element +export type ApplyTo = string | HTMLElement export interface DrawGraphIntoCanvasOptions { c: HTMLCanvasElement @@ -19,28 +19,34 @@ export interface DrawGraphIntoCanvasOptions { // First cleanup canvas export function drawGraphIntoCanvas( graph: Display, - opts: DrawGraphIntoCanvasOptions, - callback: (opt: DrawGraphIntoCanvasOptions, graph: Display) => boolean | void + opts: DrawGraphIntoCanvasOptions ) { const { ctx, dpr } = opts ctx.save() - if (asserts.isLayer(graph) && graph.__refresh__) { - callback(opts, graph) - return - } - if (asserts.isLayer(graph) || asserts.isBox(graph)) { + 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, callback) + drawGraphIntoCanvas(element, opts) } - callback(opts, graph) } 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) + if (asserts.isRoundRect(graph)) { + const effectiveWidth = graph.width - graph.style.padding * 2 + const effectiveHeight = graph.height - graph.style.padding * 2 + if (effectiveWidth <= 0 || effectiveHeight <= 0) { + ctx.restore() + return + } + if (graph.style.radius >= effectiveHeight / 2 || graph.style.radius >= effectiveWidth / 2) { + ctx.restore() + return + } + } applyCanvasTransform(ctx, matrix, dpr) graph.render(ctx) } @@ -49,7 +55,7 @@ export function drawGraphIntoCanvas( export class Schedule extends Box { render: Render - to: Element + to: HTMLElement event: Event constructor(to: ApplyTo, renderOptions: Partial = {}) { super() @@ -72,14 +78,6 @@ export class Schedule // execute all graph elements execute(render: Render, graph: Display = this) { - drawGraphIntoCanvas(graph, { c: render.canvas, ctx: render.ctx, dpr: render.options.devicePixelRatio }, (opts, graph) => { - if (asserts.isLayer(graph)) { - if (graph.__refresh__) { - graph.draw(opts.ctx) - } else { - graph.setCacheSnapshot(opts.c) - } - } - }) + drawGraphIntoCanvas(graph, { c: render.canvas, ctx: render.ctx, dpr: render.options.devicePixelRatio }) } } diff --git a/src/etoile/schedule/render.ts b/src/etoile/schedule/render.ts index b2aa620..bd9856e 100644 --- a/src/etoile/schedule/render.ts +++ b/src/etoile/schedule/render.ts @@ -14,28 +14,28 @@ export interface RenderViewportOptions { } export class Canvas { - private canvas: HTMLCanvasElement - private ctx: CanvasRenderingContext2D + canvas: HTMLCanvasElement + ctx: CanvasRenderingContext2D constructor(options: RenderViewportOptions) { this.canvas = createCanvasElement() - writeBoundingRectForCanvas(this.canvas, options.width, options.height, options.devicePixelRatio) + this.setOptions(options) this.ctx = this.canvas.getContext('2d')! } - get c() { - return { canvas: this.canvas, ctx: this.ctx } + setOptions(options: RenderViewportOptions) { + writeBoundingRectForCanvas(this.canvas, options.width, options.height, options.devicePixelRatio) } } export class Render { - private c: Canvas options: RenderViewportOptions - constructor(to: Element, options: RenderViewportOptions) { - this.c = new Canvas(options) + private container: Canvas + constructor(to: HTMLElement, options: RenderViewportOptions) { + this.container = new Canvas(options) this.options = options this.initOptions(options) if (!options.shaow) { - to.appendChild(this.canvas) + to.appendChild(this.container.canvas) } } @@ -44,16 +44,16 @@ export class Render { } get canvas() { - return this.c.c.canvas + return this.container.canvas } get ctx() { - return this.c.c.ctx + return this.container.ctx } initOptions(userOptions: Partial = {}) { Object.assign(this.options, userOptions) - writeBoundingRectForCanvas(this.canvas, this.options.width, this.options.height, this.options.devicePixelRatio) + this.container.setOptions(this.options) } destory() { diff --git a/src/index.ts b/src/index.ts index d4e4f40..f68bc07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,13 +2,32 @@ export { createTreemap } from './primitives/component' export type { App, TreemapInstanceAPI, TreemapOptions, unstable_use } from './primitives/component' export { TreemapLayout } from './primitives/component' export * from './primitives/decorator' -export type { - EventMethods, - PrimitiveEvent, - PrimitiveEventCallback, - PrimitiveEventDefinition, - PrimitiveEventMetadata -} from './primitives/event' + +import type { DOMEventType } from './etoile/native/dom' +import type { ExposedEventCallback, ExposedEventDefinition, ExposedEventMethods } from './primitives/event' + +/** + * @deprecated compat `PrimitiveEvent` using `DOMEventType` replace it. (will be removed in the next three versions.) + */ +export type PrimitiveEvent = DOMEventType + +/** + * @deprecated compat `EventMethods` using `ExposedEventMethods` replace it. (will be removed in the next three versions.) + */ +export type EventMethods = ExposedEventMethods + +/** + * @deprecated compat `PrimitiveEventCallback` using `ExposedEventCallback` replace it. (will be removed in the next three versions.) + */ +export type PrimitiveEventCallback = ExposedEventCallback + +/** + * @deprecated compat `PrimitiveEventDefinition` using `ExposedEventDefinition` replace it. (will be removed in the next three versions.) + */ +export type PrimitiveEventDefinition = ExposedEventDefinition + +export type { DOMEventType } from './etoile/native/dom' +export type { ExposedEventCallback, ExposedEventDefinition, ExposedEventMethods, PrimitiveEventMetadata } from './primitives/event' export type { LayoutModule } from './primitives/squarify' export { c2m, diff --git a/src/primitives/animation.ts b/src/primitives/animation.ts deleted file mode 100644 index 67e0d36..0000000 --- a/src/primitives/animation.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { asserts } from '../etoile' -import { Graph } from '../etoile/graph/display' -import { raf } from '../shared' - -export function applyForOpacity(graph: Graph, lastState: number, nextState: number, easedProgress: number) { - const alpha = lastState + (nextState - lastState) * easedProgress - if (asserts.isRect(graph)) { - graph.style.opacity = alpha - } -} - -export interface EffectScopeContext { - animationFrameID: number | null -} - -function createEffectRun(c: EffectScopeContext) { - return (fn: () => boolean | void) => { - const effect = () => { - const done = fn() - if (!done) { - c.animationFrameID = raf(effect) - } - } - if (!c.animationFrameID) { - c.animationFrameID = raf(effect) - } - } -} - -function createEffectStop(c: EffectScopeContext) { - return () => { - if (c.animationFrameID) { - window.cancelAnimationFrame(c.animationFrameID) - c.animationFrameID = null - } - } -} - -// Fill frame -export function createEffectScope() { - const c: EffectScopeContext = { - animationFrameID: null - } - - const run = createEffectRun(c) - const stop = createEffectStop(c) - - return { run, stop } -} diff --git a/src/primitives/component.ts b/src/primitives/component.ts index e02f7c6..7c20700 100644 --- a/src/primitives/component.ts +++ b/src/primitives/component.ts @@ -1,9 +1,11 @@ -import { Box, Layer, etoile } from '../etoile' -import { createFillBlock, createTitleText } from '../shared' +import { Bitmap, Box, etoile, writeBoundingRectForCanvas } from '../etoile' +import type { RenderViewportOptions } from '../etoile' +import type { DOMEventDefinition } from '../etoile/native/dom' +import { createCanvasElement, createRoundBlock, createTitleText } from '../shared' import type { RenderDecorator, Series } from './decorator' -import type { EventMethods, InternalEventDefinition } from './event' -import { SelfEvent, internalEventMappings } from './event' -import { registerModuleForSchedule } from './registry' +import type { ExposedEventMethods, InternalEventDefinition } from './event' +import { INTERNAL_EVENT_MAPPINGS, TreemapEvent } from './event' +import { register } from './registry' import { squarify } from './squarify' import type { LayoutModule } from './squarify' import { bindParentForModule, findRelativeNodeById } from './struct' @@ -25,10 +27,6 @@ export interface App { zoom: (id: string) => void } -const defaultRegistries = [ - registerModuleForSchedule(new SelfEvent()) -] - function measureTextWidth(c: CanvasRenderingContext2D, text: string) { return c.measureText(text).width } @@ -104,31 +102,84 @@ export function resetLayout(treemap: TreemapLayout, w: number, h: number) { treemap.reset(true) } +export class Highlight extends etoile.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' + } +} + +function createCache() { + const canvas = createCanvasElement() + const ctx = canvas.getContext('2d')! + return { + bitmap: new Bitmap(), + canvas, + ctx + } +} + +export function setCacheMetadata(canvas: HTMLCanvasElement, opts: RenderViewportOptions) { + writeBoundingRectForCanvas(canvas, opts.width, opts.height, opts.devicePixelRatio) +} + +export function cleanCacheSnapshot(c: CanvasRenderingContext2D) { + c.clearRect(0, 0, c.canvas.width, c.canvas.height) +} + +interface Caches { + fg: ReturnType + bg: ReturnType +} + export class TreemapLayout extends etoile.Schedule { data: NativeModule[] layoutNodes: LayoutModule[] decorator: RenderDecorator - private bgLayer: Layer - private fgBox: Box + bgBox: Box + fgBox: Box fontsCaches: Record ellispsisWidthCache: Record + highlight: Highlight + caches: Caches + useCache: boolean + constructor(...args: ConstructorParameters) { super(...args) this.data = [] + this.useCache = false this.layoutNodes = [] - this.bgLayer = new Layer() + this.bgBox = new Box() this.fgBox = new Box() + this.caches = { fg: createCache(), bg: createCache() } this.decorator = Object.create(null) as RenderDecorator this.fontsCaches = Object.create(null) as Record this.ellispsisWidthCache = Object.create(null) as Record - this.bgLayer.setCanvasOptions(this.render.options) + this.highlight = new Highlight(this.to, { width: this.render.options.width, height: this.render.options.height }) } drawBackgroundNode(node: LayoutModule) { const [x, y, w, h] = node.layout + const padding = 2 + if (w - padding * 2 <= 0 || h - padding * 2 <= 0) { + return + } const fill = this.decorator.color.mappings[node.node.id] - const s = createFillBlock(x, y, w, h, { fill }) - this.bgLayer.add(s) + this.bgBox.add(createRoundBlock(x, y, w, h, { fill, padding, radius: 2 })) for (const child of node.children) { this.drawBackgroundNode(child) } @@ -137,9 +188,8 @@ export class TreemapLayout extends etoile.Schedule { drawForegroundNode(node: LayoutModule) { const [x, y, w, h] = node.layout if (!w || !h) { return } - const { rectBorderWidth, titleHeight, rectGap } = node.decorator + const { titleHeight, rectGap } = node.decorator const { fontSize, fontFamily, color } = this.decorator.font - this.fgBox.add(createFillBlock(x + 0.5, y + 0.5, w, h, { stroke: '#222', lineWidth: rectBorderWidth })) let optimalFontSize if (node.node.id in this.fontsCaches) { optimalFontSize = this.fontsCaches[node.node.id] @@ -157,7 +207,6 @@ export class TreemapLayout extends etoile.Schedule { ) 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) @@ -173,15 +222,15 @@ export class TreemapLayout extends etoile.Schedule { } reset(refresh = false) { - this.remove(this.bgLayer, this.fgBox) - if (!this.bgLayer.__refresh__) { - this.bgLayer.destory() - for (const node of this.layoutNodes) { - this.drawBackgroundNode(node) - } - } else { - // Unlike foreground layer, background laer don't need clone so we should reset the loc informaton - this.bgLayer.initLoc() + if (this.useCache) { + // this.remove(this.bgBox, this.fgBox, this.caches.fg, this.caches.bg) + // this.add(this.caches.fg, this.caches.bg) + return + } + this.remove(this.bgBox, this.fgBox) + this.bgBox.destory() + for (const node of this.layoutNodes) { + this.drawBackgroundNode(node) } if (!this.fgBox.elements.length || refresh) { this.render.ctx.textBaseline = 'middle' @@ -192,20 +241,17 @@ export class TreemapLayout extends etoile.Schedule { } else { this.fgBox = this.fgBox.clone() } - this.add(this.bgLayer, this.fgBox) + this.add(this.bgBox, this.fgBox) } get api() { return { - zoom: (node: LayoutModule) => { - this.event.emit(internalEventMappings.ON_ZOOM, node) + zoom: (node: LayoutModule | null) => { + if (!node) { return } + this.event.emit(INTERNAL_EVENT_MAPPINGS.ON_ZOOM, node) } } } - - get backgroundLayer() { - return this.bgLayer - } } export function createTreemap() { @@ -229,9 +275,8 @@ export function createTreemap() { ;(root as HTMLDivElement).style.position = 'relative' if (!installed) { - for (const registry of defaultRegistries) { - registry(context, treemap, treemap.render) - } + register(TreemapEvent)(context, treemap) + installed = true } } @@ -247,16 +292,15 @@ export function createTreemap() { function resize() { if (!treemap || !root) { return } const { width, height } = root.getBoundingClientRect() - treemap.backgroundLayer.__refresh__ = false + treemap.useCache = false treemap.render.initOptions({ height, width, devicePixelRatio: window.devicePixelRatio }) treemap.render.canvas.style.position = 'absolute' - treemap.backgroundLayer.setCanvasOptions(treemap.render.options) - treemap.backgroundLayer.initLoc() - treemap.backgroundLayer.matrix = treemap.backgroundLayer.matrix.create({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment treemap.fontsCaches = Object.create(null) - treemap.event.emit(internalEventMappings.CLEAN_UP) - treemap.event.emit(internalEventMappings.ON_LOAD, width, height, root) + treemap.event.emit(INTERNAL_EVENT_MAPPINGS.ON_CLEANUP) + treemap.highlight.render.initOptions({ height, width, devicePixelRatio: window.devicePixelRatio }) + treemap.highlight.reset() + treemap.highlight.init() resetLayout(treemap, width, height) treemap.update() } @@ -291,7 +335,7 @@ export function createTreemap() { } } - return context as App & EventMethods + return context as App & ExposedEventMethods } export type TreemapInstanceAPI = TreemapLayout['api'] diff --git a/src/primitives/decorator.ts b/src/primitives/decorator.ts index 36cb312..b758dc7 100644 --- a/src/primitives/decorator.ts +++ b/src/primitives/decorator.ts @@ -38,8 +38,8 @@ export interface RenderDecorator { export const defaultLayoutOptions = { titleAreaHeight: { - max: 80, - min: 20 + max: 60, + min: 30 }, rectGap: 5, rectBorderRadius: 0.5, @@ -50,7 +50,7 @@ export const defaultFontOptions = { color: '#000', fontSize: { max: 38, - min: 7 + min: 0 }, fontFamily: 'sans-serif' } satisfies RenderFont diff --git a/src/primitives/event.ts b/src/primitives/event.ts index 74b2074..0764396 100644 --- a/src/primitives/event.ts +++ b/src/primitives/event.ts @@ -1,44 +1,31 @@ -// etoile is a simple 2D render engine for web and it don't take complex rendering into account. -// So it's no need to implement a complex event algorithm or hit mode. -// If one day etoile need to build as a useful library. Pls rewrite it! -// All of implementation don't want to consider the compatibility of the browser. - -import { Event as _Event, Schedule, asserts, drawGraphIntoCanvas, easing, etoile } from '../etoile' -import type { BindThisParameter } from '../etoile' -import { Display, S } from '../etoile/graph/display' +import { asserts, easing, etoile } from '../etoile' +import { Display, Graph, S } from '../etoile/graph/display' +import { DOMEvent, DOM_EVENTS, createEffectScope } from '../etoile/native/dom' +import type { DOMEventMetadata, DOMEventType } from '../etoile/native/dom' +import { Event as _Event } from '../etoile/native/event' +import type { BindThisParameter } from '../etoile/native/event' +import { useMagicTrackPad } from '../etoile/native/magic-trackpad' import type { ColorDecoratorResultRGB } from '../etoile/native/runtime' -import { createFillBlock, mixin } from '../shared' +import { createRoundBlock, isMacOS, mixin, prettyStrJoin } from '../shared' import type { InheritedCollections } from '../shared' -import { applyForOpacity, createEffectScope } from './animation' -import { TreemapLayout, resetLayout } from './component' import type { App, TreemapInstanceAPI } from './component' -import { RegisterModule } from './registry' +import { TreemapLayout, resetLayout } from './component' import type { LayoutModule } from './squarify' import { findRelativeNode, findRelativeNodeById } from './struct' import type { NativeModule } from './struct' -const primitiveEvents = ['click', 'mousedown', 'mousemove', 'mouseup', 'mouseover', 'mouseout'] as const - -export type PrimitiveEvent = typeof primitiveEvents[number] - export interface PrimitiveEventMetadata { native: HTMLElementEventMap[T] - module: LayoutModule + module: LayoutModule | null } -export type PrimitiveEventCallback = (metadata: PrimitiveEventMetadata) => void - -type SelfEventCallback = (metadata: PrimitiveEventMetadata) => void +export type ExposedEventCallback = (metadata: PrimitiveEventMetadata) => void -export type PrimitiveEventDefinition = { - [key in PrimitiveEvent]: BindThisParameter, TreemapInstanceAPI> +export type ExposedEventDefinition = { + [K in DOMEventType]: BindThisParameter, TreemapInstanceAPI> } -type SelfEventDefinition = PrimitiveEventDefinition & { - wheel: BindThisParameter, TreemapInstanceAPI> -} - -export interface EventMethods { +export interface ExposedEventMethods { on( evt: Evt, handler: BindThisParameter @@ -49,331 +36,298 @@ export interface EventMethods { + isDragging: false, + isWheeling: false, + isZooming: false, + currentNode: null, + forceDestroy: false, + dragX: 0, + dragY: 0 + } } -export const internalEventMappings = { - CLEAN_UP: 'self:cleanup', - ON_LOAD: 'self:onload', - ON_ZOOM: 'zoom' +interface EffectOptions { + duration: number + onStop?: () => void + deps?: Array<() => boolean> +} + +export const INTERNAL_EVENT_MAPPINGS = { + ON_ZOOM: 0o1, + ON_CLEANUP: 0o3 } as const -export type InternalEventType = typeof internalEventMappings[keyof typeof internalEventMappings] +export type InternalEventType = typeof INTERNAL_EVENT_MAPPINGS[keyof typeof INTERNAL_EVENT_MAPPINGS] export interface InternalEventMappings { - [internalEventMappings.CLEAN_UP]: () => void - [internalEventMappings.ON_LOAD]: (width: number, height: number, root: HTMLElement) => void - [internalEventMappings.ON_ZOOM]: (node: LayoutModule) => void + [INTERNAL_EVENT_MAPPINGS.ON_ZOOM]: (node: LayoutModule) => void + [INTERNAL_EVENT_MAPPINGS.ON_CLEANUP]: () => void } export type InternalEventDefinition = { [key in InternalEventType]: InternalEventMappings[key] } +const ANIMATION_DURATION = 300 + const fill = { desc: { r: 255, g: 255, b: 255 }, mode: 'rgb' } -function smoothDrawing(c: SelfEventContenxt) { - const { self } = c - const currentNode = self.currentNode +function runEffect(callback: (progress: number) => void, opts: EffectOptions) { + const effect = createEffectScope() + const startTime = Date.now() + + const condtion = (process: number) => { + if (Array.isArray(opts.deps)) { + return opts.deps.some((dep) => dep()) + } + return process >= 1 + } + + effect.run(() => { + const elapsed = Date.now() - startTime + const progress = Math.min(elapsed / opts.duration, 1) + if (condtion(progress)) { + effect.stop() + if (opts.onStop) { + opts.onStop() + } + return true + } + callback(progress) + }) +} + +export function applyForOpacity(graph: Graph, lastState: number, nextState: number, easedProgress: number) { + const alpha = lastState + (nextState - lastState) * easedProgress + if (asserts.isRoundRect(graph)) { + graph.style.opacity = alpha + } +} + +function drawHighlight(treemap: TreemapLayout, evt: TreemapEvent) { + const { highlight } = treemap + const { currentNode } = evt.state if (currentNode) { - const effect = createEffectScope() - const startTime = Date.now() - const animationDuration = 300 const [x, y, w, h] = currentNode.layout - effect.run(() => { - const elapsed = Date.now() - startTime - const progress = Math.min(elapsed / animationDuration, 1) - if (self.forceDestroy || progress >= 1) { - effect.stop() - self.highlight.reset() - self.highlight.setDisplayLayerForHighlight() - return true - } + runEffect((progress) => { const easedProgress = easing.cubicInOut(progress) - self.highlight.reset() - const mask = createFillBlock(x, y, w, h, { fill, opacity: 0.4 }) - self.highlight.highlight.add(mask) - self.highlight.setDisplayLayerForHighlight('1') + highlight.reset() + const mask = createRoundBlock(x, y, w, h, { fill, opacity: 0.4, radius: 2, padding: 2 }) + highlight.add(mask) + highlight.setZIndexForHighlight('1') applyForOpacity(mask, 0.4, 0.4, easedProgress) - stackMatrixTransform(mask, self.translateX, self.translateY, self.scaleRatio) - self.highlight.highlight.update() + stackMatrixTransform(mask, evt.matrix.e, evt.matrix.f, evt.matrix.a) + highlight.update() + }, { + duration: ANIMATION_DURATION, + deps: [() => evt.state.isDragging, () => evt.state.isWheeling, () => evt.state.isZooming] }) } else { - self.highlight.reset() - self.highlight.setDisplayLayerForHighlight() + highlight.reset() + highlight.setZIndexForHighlight() } } -function applyZoomEvent(ctx: SelfEventContenxt) { - ctx.treemap.event.on(internalEventMappings.ON_ZOOM, (node: LayoutModule) => { - const root: LayoutModule | null = null - if (ctx.self.isDragging) { return } - onZoom(ctx, node, root) - }) -} +// TODO: Do we need turn off internal events? +export class TreemapEvent extends DOMEvent { + private exposedEvent: _Event + state: TreemapEventState + zoom: ReturnType + constructor(app: App, treemap: TreemapLayout) { + super(treemap.render.canvas) + this.exposedEvent = new _Event() + this.state = createTreemapEventState() + const exposedMethods: InheritedCollections[] = [ + { name: 'on', fn: () => this.exposedEvent.bindWithContext(treemap.api) }, + { name: 'off', fn: () => this.exposedEvent.off.bind(this.exposedEvent) } + ] -function getOffset(el: HTMLElement) { - 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: HTMLElement | null = el; elt; elt = el.offsetParent as HTMLElement | null) { - e += el.offsetLeft - f += el.offsetTop - } - } + const macOS = isMacOS() - return [ - e + Math.max(document.documentElement.scrollLeft, document.body.scrollLeft), - f + Math.max(document.documentElement.scrollTop, document.body.scrollTop) - ] -} + DOM_EVENTS.forEach((evt) => { + this.on(evt, (metadata: DOMEventMetadata) => { + // if (evt === 'wheel' && macOS) { + // this.dispatch({ type: 'macOSWheel', treemap }, metadata) + // return + // } + this.dispatch({ type: evt, treemap }, metadata) + }) + }) -function captureBoxXY(c: HTMLCanvasElement, evt: unknown, a: number, d: number, translateX: number, translateY: number) { - 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) - } + mixin(app, exposedMethods) + + treemap.event.on(INTERNAL_EVENT_MAPPINGS.ON_CLEANUP, () => { + this.matrix.create({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) + this.state = createTreemapEventState() + }) + this.zoom = createOnZoom(treemap, this) + + treemap.event.on(INTERNAL_EVENT_MAPPINGS.ON_ZOOM, this.zoom) } - return { x: 0, y: 0 } -} -function bindPrimitiveEvent( - c: HTMLCanvasElement, - ctx: SelfEventContenxt, - evt: PrimitiveEvent | (string & {}), - bus: _Event -) { - const { treemap, self } = ctx - const handler = (e: unknown) => { - const { x, y } = captureBoxXY( - c, - e, - self.scaleRatio, - self.scaleRatio, - self.translateX, - self.translateY - ) - - const event = > { - native: e, - module: findRelativeNode({ x, y }, treemap.layoutNodes) - } + private dispatch(ctx: TreemapEventContext, metadata: DOMEventMetadata) { + const node = findRelativeNode(metadata.loc, ctx.treemap.layoutNodes) + + const fn = prettyStrJoin('on', ctx.type) + // @ts-expect-error safe - bus.emit(evt, event) - } - c.addEventListener(evt, handler) - return handler -} -// For best render performance. we should cache avaliable layout nodes. -export class SelfEvent extends RegisterModule { - currentNode: LayoutModule | null - forceDestroy: boolean - scaleRatio: number - translateX: number - translateY: number - layoutWidth: number - layoutHeight: number - isDragging: boolean - draggingState: DraggingState - event: _Event - triggerZoom: boolean - // eslint-disable-next-line no-use-before-define - highlight: HighlightContext - constructor() { - super() - this.currentNode = null - this.forceDestroy = false - this.isDragging = false - this.scaleRatio = 1 - this.translateX = 0 - this.translateY = 0 - this.layoutWidth = 0 - this.layoutHeight = 0 - this.draggingState = { x: 0, y: 0 } - this.event = new _Event() - this.triggerZoom = false - this.highlight = createHighlight() - } + if (typeof this[fn] === 'function') { + // @ts-expect-error safe + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + this[fn](ctx, metadata, node) + } - ondragstart(this: SelfEventContenxt, metadata: PrimitiveEventMetadata<'mousedown'>) { - const { native } = metadata - if (isScrollWheelOrRightButtonOnMouseupAndDown(native)) { - return + // note: onmouseup event will trigger click event together + if (ctx.type === 'mousemove') { + if (this.state.isDragging) { + this.exposedEvent.silent('click') + } else { + this.exposedEvent.active('click') + } } - const x = native.offsetX - const y = native.offsetY - this.self.isDragging = true - this.self.draggingState = { x, y } + + // For macOS + this.exposedEvent.emit(ctx.type === 'macOSWheel' ? 'wheel' : ctx.type, { native: metadata.native, module: node }) } - ondragmove(this: SelfEventContenxt, metadata: PrimitiveEventMetadata<'mousemove'>) { - if (!this.self.isDragging) { - if ('zoom' in this.treemap.event.eventCollections) { - const condit = this.treemap.event.eventCollections.zoom.length > 0 - if (!condit) { - applyZoomEvent(this) - } + private onmousemove(ctx: TreemapEventContext, metadata: DOMEventMetadata<'mousemove'>, node: LayoutModule | null) { + if (!this.state.isDragging) { + if (this.state.currentNode !== node) { + this.state.currentNode = node } - return - } - // If highlighting is triggered, it needs to be destroyed first - this.self.highlight.reset() - this.self.highlight.setDisplayLayerForHighlight() - this.self.event.off('mousemove', this.self.onmousemove.bind(this)) - this.treemap.event.off(internalEventMappings.ON_ZOOM) - this.self.forceDestroy = true - const { native } = metadata - const x = native.offsetX - const y = native.offsetY - const { x: lastX, y: lastY } = this.self.draggingState - const drawX = x - lastX - const drawY = y - lastY - this.self.translateX += drawX - this.self.translateY += drawY - this.self.draggingState = { x, y } - if (this.self.triggerZoom) { - refreshBackgroundLayer(this) + drawHighlight(ctx.treemap, this) + } else { + // for drag + const { treemap } = ctx + treemap.highlight.reset() + treemap.highlight.setZIndexForHighlight() + runEffect(() => { + const { offsetX: x, offsetY: y } = metadata.native + const { dragX: lastX, dragY: lastY } = this.state + const drawX = x - lastX + const drawY = y - lastY + treemap.reset() + this.matrix.translation(drawX, drawY) + Object.assign(this.state, { isDragging: true, dragX: x, dragY: y }) + stackMatrixTransformWithGraphAndLayer(treemap.elements, this.matrix.e, this.matrix.f, this.matrix.a) + treemap.update() + }, { + duration: ANIMATION_DURATION, + deps: [() => this.state.forceDestroy], + onStop: () => { + this.state.isDragging = false + } + }) } - this.treemap.reset() - stackMatrixTransformWithGraphAndLayer(this.treemap.elements, this.self.translateX, this.self.translateY, this.self.scaleRatio) - this.treemap.update() } - ondragend(this: SelfEventContenxt) { - if (!this.self.isDragging) { - return - } - this.self.isDragging = false - this.self.draggingState = { x: 0, y: 0 } - this.self.highlight.reset() - this.self.highlight.setDisplayLayerForHighlight() - this.self.event.bindWithContext(this)('mousemove', this.self.onmousemove.bind(this)) + private onmouseout(ctx: TreemapEventContext) { + this.state.currentNode = null + drawHighlight(ctx.treemap, this) } - onmousemove(this: SelfEventContenxt, metadata: PrimitiveEventMetadata<'mousemove'>) { - const { self } = this - const { module: node } = metadata - self.forceDestroy = false - if (self.currentNode !== node) { - self.currentNode = node + private onmousedown(ctx: TreemapEventContext, metadata: DOMEventMetadata<'mousedown'>) { + if (isScrollWheelOrRightButtonOnMouseupAndDown(metadata.native)) { + return } - smoothDrawing(this) + this.state.isDragging = true + this.state.dragX = metadata.native.offsetX + this.state.dragY = metadata.native.offsetY + this.state.forceDestroy = false } - onmouseout(this: SelfEventContenxt) { - const { self } = this - self.currentNode = null - self.forceDestroy = true - smoothDrawing(this) + private onmouseup(ctx: TreemapEventContext) { + if (!this.state.isDragging) { + return + } + this.state.forceDestroy = true + this.state.isDragging = false + const { treemap } = ctx + treemap.highlight.reset() + treemap.highlight.setZIndexForHighlight() } - onwheel(this: SelfEventContenxt, metadata: PrimitiveEventMetadata<'wheel'>) { - const { self, treemap } = this - + private onwheel(ctx: TreemapEventContext, metadata: DOMEventMetadata<'wheel'>) { + const { native } = metadata + const { treemap } = ctx // @ts-expect-error safe - const wheelDelta = metadata.native.wheelDelta as number + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const wheelDelta = native.wheelDelta + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const absWheelDelta = Math.abs(wheelDelta) - const offsetX = metadata.native.offsetX - const offsetY = metadata.native.offsetY - + const offsetX = native.offsetX + const offsetY = native.offsetY if (wheelDelta === 0) { return } - self.forceDestroy = true - if (self.triggerZoom) { - refreshBackgroundLayer(this) - } - treemap.reset() - this.self.highlight.reset() - this.self.highlight.setDisplayLayerForHighlight() + + this.state.forceDestroy = true + treemap.highlight.reset() + treemap.highlight.setZIndexForHighlight() const factor = absWheelDelta > 3 ? 1.4 : absWheelDelta > 1 ? 1.2 : 1.1 const delta = wheelDelta > 0 ? factor : 1 / factor - self.scaleRatio *= delta - - const translateX = offsetX - (offsetX - self.translateX) * delta - const translateY = offsetY - (offsetY - self.translateY) * delta - self.translateX = translateX - self.translateY = translateY - stackMatrixTransformWithGraphAndLayer(this.treemap.elements, this.self.translateX, this.self.translateY, this.self.scaleRatio) - treemap.update() - self.forceDestroy = false - } - - init(app: App, treemap: TreemapLayout): void { - const event = this.event - const nativeEvents: Array> = [] - const methods: InheritedCollections[] = [ - { - name: 'on', - fn: () => event.bindWithContext(treemap.api).bind(event) - }, - { - name: 'off', - fn: () => event.off.bind(event) - }, - { - name: 'emit', - fn: () => event.emit.bind(event) + const targetScaleRatio = this.matrix.a * delta + const translateX = offsetX - (offsetX - this.matrix.e) * delta + const translateY = offsetY - (offsetY - this.matrix.f) * delta + runEffect((progress) => { + this.state.isWheeling = true + treemap.reset() + const easedProgress = easing.quadraticOut(progress) + const scale = (targetScaleRatio - this.matrix.a) * easedProgress + this.matrix.a += scale + this.matrix.d += scale + this.matrix.translation((translateX - this.matrix.e) * easedProgress, (translateY - this.matrix.f) * easedProgress) + stackMatrixTransformWithGraphAndLayer(treemap.elements, this.matrix.e, this.matrix.f, this.matrix.a) + treemap.update() + }, { + duration: ANIMATION_DURATION, + onStop: () => { + this.state.forceDestroy = false + this.state.isWheeling = false } - ] - mixin(app, methods) - const selfCtx = { treemap, self: this } - const selfEvents = [...primitiveEvents, 'wheel'] as const - selfEvents.forEach((evt) => { - nativeEvents.push(bindPrimitiveEvent(treemap.render.canvas, selfCtx, evt, event)) }) - const selfEvt = event.bindWithContext(selfCtx) - selfEvt('mousedown', this.ondragstart.bind(selfCtx)) - selfEvt('mousemove', this.ondragmove.bind(selfCtx)) - selfEvt('mouseup', this.ondragend.bind(selfCtx)) - - // wheel - selfEvt('wheel', this.onwheel.bind(selfCtx)) + } - applyZoomEvent({ treemap, self: this }) + private onmacOSWheel(ctx: TreemapEventContext, metadata: DOMEventMetadata<'wheel'>) { + useMagicTrackPad(metadata.native) + } +} - let installHightlightEvent = false +function stackMatrixTransform(graph: S, e: number, f: number, scale: number) { + graph.x = graph.x * scale + e + graph.y = graph.y * scale + f + graph.scaleX = scale + graph.scaleY = scale +} - treemap.event.on(internalEventMappings.ON_LOAD, (width, height, root) => { - this.highlight.init(width, height, root) +function stackMatrixTransformWithGraphAndLayer(graphs: Display[], e: number, f: number, scale: number) { + etoile.traverse(graphs, (graph) => stackMatrixTransform(graph, e, f, scale)) +} - if (!installHightlightEvent) { - // highlight - selfEvt('mousemove', this.onmousemove.bind(selfCtx)) - selfEvt('mouseout', this.onmouseout.bind(selfCtx)) - installHightlightEvent = true - this.highlight.setDisplayLayerForHighlight() - } - this.highlight.reset() - }) +interface DuckE { + which: number +} - treemap.event.on(internalEventMappings.CLEAN_UP, () => { - this.currentNode = null - this.scaleRatio = 1 - this.translateX = 0 - this.translateY = 0 - this.layoutWidth = treemap.render.canvas.width - this.layoutHeight = treemap.render.canvas.height - this.isDragging = false - this.triggerZoom = false - this.draggingState = { x: 0, y: 0 } - }) - } +// Only works for mouseup and mousedown events +function isScrollWheelOrRightButtonOnMouseupAndDown(e: E) { + return e.which === 2 || e.which === 3 } function estimateZoomingArea(node: LayoutModule, root: LayoutModule | null, w: number, h: number) { @@ -411,136 +365,40 @@ function estimateZoomingArea(node: LayoutModule, root: LayoutModule | null, w: n return [w * scaleFactor, h * scaleFactor] } -function stackMatrixTransform(graph: S, e: number, f: number, scale: number) { - graph.x = graph.x * scale + e - graph.y = graph.y * scale + f - graph.scaleX = scale - graph.scaleY = scale -} - -function stackMatrixTransformWithGraphAndLayer(graphs: Display[], e: number, f: number, scale: number) { - etoile.traverse(graphs, (graph) => stackMatrixTransform(graph, e, f, scale)) -} - -function onZoom(ctx: SelfEventContenxt, node: LayoutModule, root: LayoutModule | null) { - if (!node) { return } - const { treemap, self } = ctx - self.forceDestroy = true - const c = treemap.render.canvas - const boundingClientRect = c.getBoundingClientRect() - const [w, h] = estimateZoomingArea(node, root, boundingClientRect.width, boundingClientRect.height) - if (self.layoutHeight !== w || self.layoutHeight !== h) { - // remove font caches +function createOnZoom(treemap: TreemapLayout, evt: TreemapEvent) { + let root: LayoutModule | null = null + return (node: LayoutModule) => { + evt.state.isZooming = true + 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] - } - resetLayout(treemap, w, h) - const module = findRelativeNodeById(node.node.id, treemap.layoutNodes) - if (module) { - const [mx, my, mw, mh] = module.layout - const scale = Math.min(boundingClientRect.width / mw, boundingClientRect.height / mh) - const translateX = (boundingClientRect.width / 2) - (mx + mw / 2) * scale - const translateY = (boundingClientRect.height / 2) - (my + mh / 2) * scale - const initialScale = self.scaleRatio - const initialTranslateX = self.translateX - const initialTranslateY = self.translateY - const startTime = Date.now() - const animationDuration = 300 - - const { run, stop } = createEffectScope() - run(() => { - const elapsed = Date.now() - startTime - const progress = Math.min(elapsed / animationDuration, 1) - treemap.backgroundLayer.__refresh__ = false - if (progress >= 1) { - stop() - self.layoutWidth = w - self.layoutHeight = h - self.forceDestroy = false - self.triggerZoom = true - return true - } - const easedProgress = easing.cubicInOut(progress) - const scaleRatio = initialScale + (scale - initialScale) * easedProgress - self.translateX = initialTranslateX + (translateX - initialTranslateX) * easedProgress - self.translateY = initialTranslateY + (translateY - initialTranslateY) * easedProgress - self.scaleRatio = scaleRatio - treemap.reset() - stackMatrixTransformWithGraphAndLayer(treemap.elements, self.translateX, self.translateY, scaleRatio) - treemap.update() - }) - } - root = node -} - -interface DuckE { - which: number -} - -// Only works for mouseup and mousedown events -function isScrollWheelOrRightButtonOnMouseupAndDown(e: E) { - return e.which === 2 || e.which === 3 -} - -interface HighlightContext { - init: (w: number, h: number, root: HTMLElement) => void - reset: () => void - setDisplayLayerForHighlight: (layer?: string) => void - get highlight(): Schedule -} - -function createHighlight(): HighlightContext { - let s: Schedule | null = null - - const setDisplayLayerForHighlight = (layer: string = '-1') => { - if (!s) { return } - const c = s.render.canvas - c.style.zIndex = layer - } - - const init: HighlightContext['init'] = (w, h, root) => { - if (!s) { - s = new Schedule(root, { width: w, height: h }) + resetLayout(treemap, w, h) + const module = findRelativeNodeById(node.node.id, treemap.layoutNodes) + if (module) { + const [mx, my, mw, mh] = module.layout + const scale = Math.min(boundingClientRect.width / mw, boundingClientRect.height / mh) + const translateX = (boundingClientRect.width / 2) - (mx + mw / 2) * scale + const translateY = (boundingClientRect.height / 2) - (my + mh / 2) * scale + const initialScale = evt.matrix.a + const initialTranslateX = evt.matrix.e + const initialTranslateY = evt.matrix.f + runEffect((progess) => { + const easedProgress = easing.cubicInOut(progess) + const scaleRatio = initialScale + (scale - initialScale) * easedProgress + evt.matrix.a = scaleRatio + evt.matrix.d = scaleRatio + evt.matrix.e = initialTranslateX + (translateX - initialTranslateX) * easedProgress + evt.matrix.f = initialTranslateY + (translateY - initialTranslateY) * easedProgress + treemap.reset() + stackMatrixTransformWithGraphAndLayer(treemap.elements, evt.matrix.e, evt.matrix.f, evt.matrix.a) + treemap.update() + }, { + duration: ANIMATION_DURATION, + onStop: () => evt.state.isZooming = false + }) } - setDisplayLayerForHighlight() - s.render.canvas.style.position = 'absolute' - s.render.canvas.style.pointerEvents = 'none' - } - - const reset = () => { - if (!s) { return } - s.destory() - s.update() - } - - return { - init, - reset, - setDisplayLayerForHighlight, - get highlight() { - return s! - } - } -} - -function refreshBackgroundLayer(c: SelfEventContenxt): boolean | void { - const { treemap, self } = c - const { backgroundLayer, render } = treemap - const { canvas, ctx, options: { width: ow, height: oh } } = render - const { layoutWidth: sw, layoutHeight: sh, scaleRatio: ss } = self - - const capture = sw * ss >= ow && sh * ss >= oh - backgroundLayer.__refresh__ = false - if (!capture && !self.forceDestroy) { - resetLayout(treemap, sw * ss, sh * ss) - render.clear(ow, oh) - const { dpr } = backgroundLayer.cleanCacheSnapshot() - drawGraphIntoCanvas(backgroundLayer, { c: canvas, ctx, dpr }, (opts, graph) => { - if (asserts.isLayer(graph) && !graph.__refresh__) { - graph.setCacheSnapshot(opts.c) - } - }) - self.triggerZoom = false - return true + root = node } } diff --git a/src/primitives/registry.ts b/src/primitives/registry.ts index c48a532..c29303a 100644 --- a/src/primitives/registry.ts +++ b/src/primitives/registry.ts @@ -1,15 +1,8 @@ -import { Render } from '../etoile' -import { log } from '../etoile/native/log' import { TreemapLayout } from './component' import type { App } from './component' -export abstract class RegisterModule { - abstract init(app: App, treemap: TreemapLayout, render: Render): void -} - -export function registerModuleForSchedule(mod: RegisterModule) { - if (mod instanceof RegisterModule) { - return (app: App, treemap: TreemapLayout, render: Render) => mod.init(app, treemap, render) +export function register(Mod: new (app: App, treemap: TreemapLayout) => M) { + return (app: App, treemap: TreemapLayout) => { + new Mod(app, treemap) } - throw new Error(log.error('The module is not a valid RegisterScheduleModule.')) } diff --git a/src/shared/index.ts b/src/shared/index.ts index f4bd40c..bfab93c 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,5 +1,5 @@ -import { Rect, Text } from '../etoile' -import type { RectStyleOptions } from '../etoile/graph/rect' +import { RoundRect, Text } from '../etoile' +import type { RoundRectStyleOptions } from '../etoile/graph/rect' import { Matrix2D } from '../etoile/native/matrix' export function hashCode(str: string) { @@ -20,8 +20,8 @@ export function perferNumeric(s: string | number) { export function noop() {} -export function createFillBlock(x: number, y: number, width: number, height: number, style?: Partial) { - return new Rect({ width, height, x, y, style }) +export function createRoundBlock(x: number, y: number, width: number, height: number, style?: Partial) { + return new RoundRect({ width, height, x, y, style: { ...style } }) } export function createTitleText(text: string, x: number, y: number, font: string, color: string) { @@ -56,3 +56,18 @@ export function mixin(app: T, methods: InheritedCollections[]) { }) }) } + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type Tail = T extends readonly [infer _, ...infer Rest] ? Rest : [] + +type StrJoin = T extends readonly [] ? '' + : T extends readonly [infer FF] ? FF + : `${F}${StrJoin, T[0]>}` + +export function prettyStrJoin(...s: T) { + return s.join('') as StrJoin +} + +export function isMacOS() { + return /Mac OS X/.test(navigator.userAgent) +}