diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/CanvasRenderer.ts b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/CanvasRenderer.ts new file mode 100644 index 0000000000000000000000000000000000000000..32a591cf7949217750709989e980ce37c87c86bd --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/CanvasRenderer.ts @@ -0,0 +1,126 @@ +import { useEffect, useReducer } from "react" + +export class Subscriptions { + subs = new Set<() => void>() + + subscribe = (fn: () => void): (() => void) => { + this.subs.add(fn) + return () => this.subs.delete(fn) + } + + notify = () => { + for (const sub of this.subs) sub() + } +} + +export function useSubscriptions(subscription: Subscriptions | null) { + const [, refresh] = useReducer(a => a + 1, 0) + useEffect(() => subscription?.subscribe(refresh), [subscription]) +} + +type ModelData = { + nodes: any[] + edges: any +} + +type RenderState = { + canvas: HTMLCanvasElement + ctx: CanvasRenderingContext2D +} + +type ProgramState = { + render?: RenderState + htmlSubs: Subscriptions + queue: any[] +} + +class CanvasRenderer { + programState: ProgramState + stopped = false + canvasSizeDirty = true + + prevTime = performance.now() + rafHandle = 0 + isDirty = false + isWaitingForSync = false + + constructor( + canvas: HTMLCanvasElement, + private modelData: ModelData + ) { + this.programState = { + htmlSubs: new Subscriptions(), + queue: [] + } + } + + destroy() { + this.stopped = true + } + + setData(newData: ModelData) { + this.modelData = newData + + this.markDirty() + } + + markDirty() { + if (!this.modelData || this.stopped) return + + this.isDirty = true + + if (!this.rafHandle) { + this.prevTime = performance.now() + this.rafHandle = requestAnimationFrame(this.loop) + } + } + + loop(time: number) { + if (!(this.isDirty || this.isWaitingForSync) || this.stopped) { + this.rafHandle = 0 + return + } + + const wasDirty = this.isDirty + + this.isDirty = false + this.isWaitingForSync = false + + let dt = time - this.prevTime + this.prevTime = time + + if (dt < 8) dt = 16 + + this.checkForSyncObjects() + const prevSyncCount = this.programState.queue.length ?? 0 + + if (wasDirty || this.isDirty) this.render(time, dt) + + const newSyncCount = this.programState.queue.length ?? 0 + + if (newSyncCount !== prevSyncCount) this.isWaitingForSync = true + + this.rafHandle = requestAnimationFrame(this.loop) + } + + checkForSyncObjects() { + if (!this.programState.render) return + + return [] + } + + render(time: number, dt: number) { + if (!this.programState.render) return + + const canvas = this.programState.render.canvas + + if (this.canvasSizeDirty) { + const bcr = canvas.getBoundingClientRect() + canvas.width = bcr.width * devicePixelRatio + canvas.height = bcr.height * devicePixelRatio + this.canvasSizeDirty = false + } + + this.programState.htmlSubs.notify() + } +} diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/Control.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/Control.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b725f32bfa9837caf36141a2411a5a36adef5413 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/Control.tsx @@ -0,0 +1,115 @@ +import { Tooltip } from "ui" +import { gridVisibleAtom, useZoom } from "stores" +import { useSetAtom } from "jotai" + +const Control = () => { + const setGridVisible = useSetAtom(gridVisibleAtom) + const [zoom, zoomIn, zoomOut, resetZoom] = useZoom() + + const toggleGrid = () => setGridVisible(prev => !prev) + + return ( +
+ + + + + + + + + + + + + + + +
+ ) +} + +export default Control diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/LayerPanel.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/LayerPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c574f0502334e82749470a3449e7cc13f84fc78c --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/LayerPanel.tsx @@ -0,0 +1,97 @@ +import { useAtomValue } from "jotai" +import { Tooltip } from "ui" +import { rawModelDataAtom, layerAtom } from "stores" + +const LayerPanel = () => { + const { nodes, parameters } = useAtomValue(rawModelDataAtom) + const id = useAtomValue(layerAtom) + + if (!id) return <> + + const obj = (nodes as Record)[id] as IRNode + + return ( +
+

NODE PROPERTIES

+
+
Op
+
{obj.opType}
+
+
+
Name
+
{id}
+
+ +
+ +

ATTRIBUTES

+
+
dtype(dims)
+
{obj.shapes}
+
+ +
+ +

INPUTS

+
+
+ {obj.input.map(i => ( + + ))} +
+
+ +
+ +

OUTPUTS

+
+
+ {obj.output.map(i => ( + + ))} +
+
+
+ ) +} + +type Props = { + id: IRNodeId + nodes: any + parameters: any +} + +const Line = ({ id, nodes, parameters }: Props) => { + const node: IRNode = nodes[id] + + if (node) + return ( +
+ + {id} + +
{node.opType}
+
+ ) + + const parameter = parameters[id] + + if (parameter) + return ( +
+ + {id} + +
{parameter}
+
+ ) + + return <> +} + +export default LayerPanel diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/Minimap.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/Minimap.tsx new file mode 100644 index 0000000000000000000000000000000000000000..90ca740aa433e55d12703e45fcca12a1b5480ae5 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/Minimap.tsx @@ -0,0 +1,43 @@ +import { useEffect, useRef } from "react" + +const width = 300 +const height = 5000 + +const Minimap = () => { + const ref = useRef(null!) + + useEffect(() => { + const canvas = ref.current + + const ctx = canvas.getContext("2d")! + + const b = Math.max(width, height) + const ratio = (150 / b) * 0.9 + + const aw = width * ratio + const ah = height * ratio + + ctx.scale(devicePixelRatio, devicePixelRatio) + + ctx.lineWidth = 3 + ctx.strokeStyle = "cyan" + ctx.strokeRect(75 - aw / 2, 75 - ah / 2, aw, ah) + + return () => { + ctx.clearRect(0, 0, 150, 150) + ctx.resetTransform() + } + }, []) + + return ( + + ) +} + +export default Minimap diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/adjust.ts b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/adjust.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4485c5d18da93dc9234c74cf22ebde019e55a1c --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/adjust.ts @@ -0,0 +1,18 @@ +/** + * Adjusts the node coordinates to account for canvas translation and scaling. + * + * Since drawing on a canvas may involve translation (translate) and scaling (zoom) transformations, + * mouse event coordinates are relative to the original coordinate system of the canvas element. + * This function is used to convert node coordinates to their adjusted positions after applying translation and scaling. + * + * @param node - An object containing the node's position and dimensions. + * @param translate - A translation vector containing x and y offsets. + * @param zoom - The scaling factor. + * @returns Returns an array of four numbers representing the adjusted coordinates of the node's left, right, top, and bottom boundaries. + */ +export const adjust = ({ x, y, width, height }: RenderNode, translate: Point, zoom: number) => [ + (x - width / 2) * zoom + translate.x, + (x + width / 2) * zoom + translate.x, + (y - height / 2) * zoom + translate.y, + (y + height / 2) * zoom + translate.y +] diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/index.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e8cf3a6f2e161c4322871a65169cb3015f381203 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/index.tsx @@ -0,0 +1,160 @@ +import { useAtom, useAtomValue } from "jotai" +import { type MouseEvent, useEffect, useMemo, useRef, useState, type WheelEvent } from "react" +import { Grid, Responsive, type WindowSize } from "ui" +import { clsx, debounce } from "libs" +import LayerPanel from "./LayerPanel" +import layout from "layout" +import { isPointOnEdge } from "./is-point-on-edge" +import { adjust } from "./adjust" +import Control from "./Control" +import { + modelPathAtom, + rawModelDataAtom, + layerAtom, + nodesEdgesAtom, + translateAtom, + useZoom, + visibleAtom +} from "stores" +import { useSetAtom } from "jotai" +import { SearchModal } from "features" +import { fuse } from "fuzzy-search" +import { RenderWorker } from "../workers" + +const ModelStructureComp = ({ width, height }: WindowSize) => { + const [isDragging, setIsDragging] = useState(false) + const [origin, setOrigin] = useState({ x: 0, y: 0 }) + const modelPath = useAtomValue(modelPathAtom) + const rawModelData = useAtomValue(rawModelDataAtom) + const visible = useAtomValue(visibleAtom) + + const [translate, setTranslate] = useAtom(translateAtom) + const [zoom, zoomIn, zoomOut] = useZoom() + + const [layer, setLayer] = useAtom(layerAtom) + const setNodesEdges = useSetAtom(nodesEdgesAtom) + + const ref = useRef(null!) + + const bbox = useMemo(() => { + const canvas = ref.current + if (!canvas) return { left: 0, top: 0 } + + return canvas.getBoundingClientRect() + }, [ref, width, height]) + + const handleMouseDown = ({ clientX, clientY }: MouseEvent) => { + setIsDragging(true) + setOrigin({ x: translate.x - clientX, y: translate.y - clientY }) + } + + const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { + if (!isDragging) return + + const translateX = clientX + origin.x + const translateY = clientY + origin.y + + setTranslate({ x: translateX, y: translateY }) + } + + const handleMouseUp = () => { + if (isDragging) setIsDragging(false) + } + + const handleWheel = debounce(({ deltaY, ctrlKey, metaKey }: WheelEvent) => { + if (ctrlKey || metaKey) deltaY > 0 ? zoomIn() : zoomOut() + else + setTranslate({ + x: translate.x, + y: translate.y - Math.round(deltaY / 3) + }) + }, 8) + + const hitTest = (cx: number, cy: number) => { + for (const node of visible!.visibleNodes) { + const [x0, x1, y0, y1] = adjust(node, translate, zoom) + + if (cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1) return node.id + } + + const px = (cx - translate.x) / zoom + const py = (cy - translate.y) / zoom + for (const edge of visible!.visibleEdges) + if (isPointOnEdge(px, py, edge.points)) return edge.head.id + } + + const handleClick = ({ clientX, clientY }: MouseEvent) => { + const id = hitTest(clientX - bbox.left, clientY - bbox.top) + + if (id !== layer) setLayer(id) + } + + useEffect(() => { + if (!width) return + + layout(rawModelData).then(res => { + fuse.setCollection(res.nodes) + setTranslate({ x: width / 2 - res.maxX, y: height / 2 - res.maxY }) + setNodesEdges(res) + + const canvas = ref.current + + try { + const offscreenCanvas = canvas.transferControlToOffscreen() + RenderWorker.postMessage( + { + type: "render", + offscreenCanvas, + data: res, + width, + height, + devicePixelRatio, + translate + }, + [offscreenCanvas] + ) + } catch (e) { + RenderWorker.postMessage({ + type: "new", + data: res, + translate + }) + } + }) + }, [width, modelPath]) + + useEffect(() => { + RenderWorker.postMessage({ type: "update", translate, zoom }) + }, [translate, zoom]) + + return ( + + ) +} + +const ModelStructure = () => ( + + {props => ( + <> + + + + + + + )} + +) + +export default ModelStructure diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/is-point-on-edge.spec.ts b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/is-point-on-edge.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2dfcd73159b6b691fb2a43913b5e2ac43012928 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/is-point-on-edge.spec.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from "bun:test" +import { isPointOnEdge } from "./is-point-on-edge" + +describe("isPointOnEdge", () => { + it("should return true if the point is on an edge", () => { + const points: Point[] = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ] + const cx = 50 + const cy = 0 + + expect(isPointOnEdge(cx, cy, points)).toBe(true) + }) + + it("should return false if the point is not on any edge", () => { + const points: Point[] = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ] + const cx = 50 + const cy = 50 + + expect(isPointOnEdge(cx, cy, points)).toBe(false) + }) + + it("should return true if the point is within the threshold distance to an edge", () => { + const points: Point[] = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ] + const cx = 10 + const cy = 5 + + expect(isPointOnEdge(cx, cy, points)).toBe(true) + }) + + it("should return false if the point is exactly on a vertex but not within the threshold distance to an edge", () => { + const points: Point[] = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ] + const cx = 0 + const cy = 0 + const threshold = 5 + + expect(isPointOnEdge(cx, cy, points, threshold)).toBe(true) + }) + + it("should handle a degenerate polygon (single point)", () => { + const points: Point[] = [{ x: 50, y: 50 }] + const cx = 50 + const cy = 50 + + expect(isPointOnEdge(cx, cy, points)).toBe(false) + }) + + it("should handle a degenerate polygon (two identical points)", () => { + const points: Point[] = [ + { x: 50, y: 50 }, + { x: 50, y: 50 } + ] + const cx = 50 + const cy = 50 + + expect(isPointOnEdge(cx, cy, points)).toBe(true) + }) + + it("should return true if the point is on an edge with irregular points", () => { + const points: Point[] = [ + { x: 20, y: 30 }, + { x: 80, y: 40 }, + { x: 60, y: 90 }, + { x: 10, y: 70 } + ] + const cx = 50 + const cy = 55 + + expect(isPointOnEdge(cx, cy, points)).toBe(false) + }) + + it("should return false if the point is not on any edge with irregular points", () => { + const points: Point[] = [ + { x: 15, y: 25 }, + { x: 75, y: 35 }, + { x: 55, y: 85 }, + { x: 5, y: 65 } + ] + const cx = 40 + const cy = 40 + + expect(isPointOnEdge(cx, cy, points)).toBe(false) + }) + + it("should return true if the point is within the threshold distance to an edge with irregular points", () => { + const points: Point[] = [ + { x: 25, y: 35 }, + { x: 70, y: 45 }, + { x: 65, y: 95 }, + { x: 15, y: 75 } + ] + const cx = 30 + const cy = 45 + const threshold = 10 + + expect(isPointOnEdge(cx, cy, points, threshold)).toBe(true) + }) + + it("should return false if the point is exactly on a vertex but not within the threshold distance to an edge with irregular points", () => { + const points: Point[] = [ + { x: 30, y: 40 }, + { x: 85, y: 50 }, + { x: 70, y: 100 }, + { x: 20, y: 80 } + ] + const cx = 30 + const cy = 40 + const threshold = 5 + + expect(isPointOnEdge(cx, cy, points, threshold)).toBe(true) + }) + + it("should handle a degenerate polygon with irregular single point", () => { + const points: Point[] = [{ x: 45, y: 55 }] + const cx = 45 + const cy = 55 + + expect(isPointOnEdge(cx, cy, points)).toBe(false) + }) + + it("should handle a degenerate polygon with irregular two identical points", () => { + const points: Point[] = [ + { x: 45, y: 55 }, + { x: 45, y: 55 } + ] + const cx = 45 + const cy = 55 + + expect(isPointOnEdge(cx, cy, points)).toBe(true) + }) +}) diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/is-point-on-edge.ts b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/is-point-on-edge.ts new file mode 100644 index 0000000000000000000000000000000000000000..779642efd265ae54a70f86c9fcc23a34de3f6c03 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ModelStructure/is-point-on-edge.ts @@ -0,0 +1,50 @@ +/** + * Calculates the shortest distance from a point to a line segment. + * @param cx The x-coordinate of the external point. + * @param cy The y-coordinate of the external point. + * @param p1 One endpoint of the line segment. + * @param p2 The other endpoint of the line segment. + * @returns The shortest distance from the point to the line segment. + */ +const distanceToPointOnSegment = (cx: number, cy: number, p1: Point, p2: Point): number => { + const dx = p2.x - p1.x + const dy = p2.y - p1.y + + // If the line segment is a single point, return the distance to that point + if (dx === 0 && dy === 0) return Math.sqrt((cx - p1.x) ** 2 + (cy - p1.y) ** 2) + + const t = ((cx - p1.x) * dx + (cy - p1.y) * dy) / (dx ** 2 + dy ** 2) + + // If the projection falls outside the segment, return the distance to the nearest endpoint + if (t < 0) return Math.sqrt((cx - p1.x) ** 2 + (cy - p1.y) ** 2) + if (t > 1) return Math.sqrt((cx - p2.x) ** 2 + (cy - p2.y) ** 2) + + const closestX = p1.x + t * dx + const closestY = p1.y + t * dy + + // Return the distance to the closest point on the segment + return Math.sqrt((cx - closestX) ** 2 + (cy - closestY) ** 2) +} + +/** + * Checks if a point is within a certain threshold distance to any edge of a polygon defined by a list of points. + * @param cx The x-coordinate of the point to check. + * @param cy The y-coordinate of the point to check. + * @param points An array of points defining the edges of the polygon. + * @param threshold The maximum distance allowed for the point to be considered "on" an edge. + * @returns True if the point is within the threshold distance to edge, otherwise false. + */ +export const isPointOnEdge = (cx: number, cy: number, points: Point[], threshold = 10): boolean => { + for (let i = 0; i < points.length - 1; i++) { + const p1 = points[i] + const p2 = points[i + 1] + + const distance = distanceToPointOnSegment(cx, cy, p1, p2) + + // If the distance to the current edge is within the threshold, return true + if (distance <= threshold) return true + } + + // If no edge is within the threshold, return false + return false +} diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/layout.ts b/plugins/mindstudio-insight-plugins/ModelVis/app/src/layout.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ecee61b6c35adda277712cb9aec04f112d86f02 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/layout.ts @@ -0,0 +1,12 @@ +import { LayoutWorker } from "./workers" + +const layout = (rawModel: any): Promise => + new Promise((resolve, reject) => { + LayoutWorker.postMessage(rawModel) + + LayoutWorker.onmessage = ev => resolve(ev.data) + LayoutWorker.onerror = error => reject(error) + }) + +export default layout + diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/Grid.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/Grid.tsx new file mode 100644 index 0000000000000000000000000000000000000000..664fb0601252adf848414a51265417e17a997495 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/Grid.tsx @@ -0,0 +1,70 @@ +import { useEffect, useRef } from "react" +import type { WindowSize } from "./Responsive" +import { useAtomValue } from "jotai" +import { gridVisibleAtom } from "stores" + +export const Grid = ({ width, height }: WindowSize) => { + const ref = useRef(null!) + + const gridVisible = useAtomValue(gridVisibleAtom) + + useEffect(() => { + if (!gridVisible || width < 100) return + + const ctx = ref.current.getContext("2d")! + ctx.scale(devicePixelRatio, devicePixelRatio) + + renderGrid(ctx, width, height) + + return () => { + ctx.clearRect(0, 0, width, height) + ctx.resetTransform() + } + }, [width, height, gridVisible]) + + return ( + + ) +} + +const renderGrid = ( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + spacing = 30, + color = "#ccc" +) => { + ctx.save() + + const x = Math.floor(height / spacing) + const py = height - spacing * x + + for (let i = 0; i < x; i++) { + ctx.moveTo(0, spacing * i - 0.5 + py) + ctx.lineTo(width, spacing * i - 0.5 + py) + } + + const y = Math.floor(width / spacing) + const px = width - spacing * y + + for (let j = 0; j < y; j++) { + ctx.moveTo(spacing * j + px, 0) + ctx.lineTo(spacing * j + px, height) + } + + ctx.lineWidth = 1 + ctx.strokeStyle = color + ctx.stroke() + + ctx.restore() +} diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/Responsive.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/Responsive.tsx new file mode 100644 index 0000000000000000000000000000000000000000..413eb181673fed92b9fb4d46cfedfd2fecee7a09 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/Responsive.tsx @@ -0,0 +1,39 @@ +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react" + +export type WindowSize = { + width: number + height: number +} + +type Props = { + children: (args: WindowSize) => ReactNode + className?: string +} + +export function Responsive({ + children, + className + }: Props) { + const ref = useRef(null!) + + const [state, setState] = useState({ width: 0, height: 0 }) + + const resize = useMemo(() => (incoming: WindowSize) => setState(incoming), []) + + useEffect(() => { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) resize({ + width: Math.floor(entry.contentRect.width), + height: Math.floor(entry.contentRect.height) + }) + }) + + observer.observe(ref.current) + + return () => observer.disconnect() + }, [resize]) + + return
+ {children({ ...state })} +
+} diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/Switch.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/Switch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6dea10beeb04534b3744bfee62f4f966e5d59eff --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/Switch.tsx @@ -0,0 +1,24 @@ +import { clsx } from "libs" + +type Props = { + value: boolean + onChange: (newState: boolean) => void +} + +export const Switch = ({value, onChange}: Props) => { + const toggle = () => onChange(!value) + + return ( +
+ +
+ ) +} diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/Tooltip.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/Tooltip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bca91566d2a51eaecaff3065abe0a8eed30104d8 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/Tooltip.tsx @@ -0,0 +1,64 @@ +import { + autoUpdate, + flip, + FloatingPortal, + offset, + shift, + useDismiss, + useFloating, + useFocus, + useHover, + useInteractions, + useRole +} from "@floating-ui/react" +import { type HTMLProps, type ReactNode, useState } from "react" + +type Props = { + children: ReactNode + content: ReactNode +} & HTMLProps + +export const Tooltip = ({ children, content, ...rest }: Props) => { + const [isOpen, setIsOpen] = useState(false) + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + placement: "top", + whileElementsMounted: autoUpdate, + middleware: [ + offset(16), + flip({ + fallbackAxisSideDirection: "start" + }), + shift() + ] + }) + + const hover = useHover(context, { move: false }) + const focus = useFocus(context) + const dismiss = useDismiss(context) + const role = useRole(context, { role: "tooltip" }) + + const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]) + + return ( + <> +
+ {children} +
+ + {isOpen && ( +
+ {content} +
+ )} +
+ + ) +} diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/index.ts b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..08f345ae77d25ae80fbde5e63055b7f8fb94a5e3 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/ui/index.ts @@ -0,0 +1,4 @@ +export * from "./Responsive" +export * from "./Grid" +export * from "./Tooltip" +export * from "./Switch" diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/workers.ts b/plugins/mindstudio-insight-plugins/ModelVis/app/src/workers.ts new file mode 100644 index 0000000000000000000000000000000000000000..40560876d487ba7f44a47101ea89b43e9c5c33b2 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/workers.ts @@ -0,0 +1,3 @@ +export const LayoutWorker = new Worker(new URL("src-worker/layouter.ts", import.meta.url)) + +export const RenderWorker = new Worker(new URL("src-worker/render.ts", import.meta.url))