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
+
+
+
+
+
+
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))