diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/LanguageSwitch.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/LanguageSwitch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..403a7f8541bea22866bc5c07fc8648c3502b69a4 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/LanguageSwitch.tsx @@ -0,0 +1,96 @@ +import { motion } from "framer-motion" +import { clsx, languages } from "libs" +import { useClickOutside, useLanguage, useVisible } from "hooks" +import { type SVGProps, useEffect } from "react" + +const Translation = (props: SVGProps) => ( + + + + +) + +const LanguageSwitch = () => { + const { visible, open, close } = useVisible() + const [appLanguage, setLanguage] = useLanguage() + + const ref = useClickOutside(close) + + useEffect(() => { + ;(async () => { + const code = localStorage.getItem("lang") ?? "en" + await setLanguage(code as LangCode) + })() + }, []) + + const handleLanguageChange = async (code: LangCode) => { + if (code !== appLanguage.code) { + await setLanguage(code) + close() + } + } + + const animate = { x: ["-50%", "-50%"], opacity: [0.3, 1] } + + return ( +
+ + {visible && ( + + {languages.map(language => ( + + ))} + + )} +
+ ) +} + +type Props = { + language: Language + current: AppLanguage + onClick: (code: LangCode) => void +} + +const LanguageItem = ({ language: { code, label }, current, onClick }: Props) => { + const handleClick = () => onClick(code) + + return ( +
  • + {label} +
  • + ) +} + +export default LanguageSwitch diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/NewProject/Compare.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/NewProject/Compare.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a4929ee2e025d1175313cea698cb84223012c09c --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/NewProject/Compare.tsx @@ -0,0 +1,97 @@ +import { open } from "@tauri-apps/plugin-dialog" +import { useSetAtom } from "jotai" +import { useState } from "react" +import { ProjectType, comparePathAtom } from "stores" + +const Compare = () => { + const [lhs, setLhs] = useState("") + const [rhs, setRhs] = useState("") + const setComparePath = useSetAtom(comparePathAtom) + + const handleLhs = async () => { + const path = await open({ + multiple: false, + directory: false, + canCreateDirectories: true + }) + + if (path) setLhs(path) + } + + const handleRhs = async () => { + const path = await open({ + multiple: false, + directory: false, + canCreateDirectories: true + }) + + if (path) setRhs(path) + } + + const confirm = () => { + setComparePath({ + type: ProjectType.Compare, + lhs, + rhs + }) + } + + return ( +
    +
    + 模型1 + +
    +
    + 模型2 + +
    + +
    + ) +} + +type FileUploadProps = { + value: string + onChange: (newVal: string) => void +} + +const FileUpload = ({ value, onChange }: FileUploadProps) => { + const handleFilePick = async () => { + const path = await open({ + directory: false, + multiple: false, + canCreateDirectories: true + }) + + if (path) onChange(path) + } + + return ( +
    + + + + + +
    + ) +} + +export default Compare diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/NewProject/Single.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/NewProject/Single.tsx new file mode 100644 index 0000000000000000000000000000000000000000..22aae91e82c3604d174a0dd4e03fb8da7054911a --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/NewProject/Single.tsx @@ -0,0 +1,19 @@ +import { useNewProject } from "hooks" + +const Single = () => { + const handleNewProject = useNewProject() + + return ( +
    + +
    + ) +} + +export default Single diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/NewProject/index.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/NewProject/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..18279009eee9a5c6e02e0331423955a9c9902111 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/NewProject/index.tsx @@ -0,0 +1,22 @@ +import { useState } from "react" +import { Switch } from "ui" +import Compare from "./Compare" +import Single from "./Single" + +export const NewProject = () => { + const [single, setSingle] = useState(true) + + const toggle = (newVal: boolean) => setSingle(newVal) + + // return ( + //
    + // + // {single ? : } + //
    + // ) + return ( +
    + +
    + ) +} diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Project.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Project.tsx new file mode 100644 index 0000000000000000000000000000000000000000..84347410cda55a83f22b2b6414b8997f8b2bc62c --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Project.tsx @@ -0,0 +1,116 @@ +import { useAtom, useSetAtom } from "jotai" +import { modelPathAtom, rawModelDataAtom, recentProjCache } from "stores" +import { RenderWorker } from "workers" +import { useClickOutside, useNewProject, useVisible } from "hooks" +import { invoke } from "@tauri-apps/api/core" + +const Project = () => { + const { visible, open, close } = useVisible() + + const ref = useClickOutside(close) + + const [modelPath, setModelPath] = useAtom(modelPathAtom) + const setModelData = useSetAtom(rawModelDataAtom) + + const reset = () => { + RenderWorker.postMessage({ type: "destroy" }) + setModelData(null) + setModelPath(null) + } + + const handleNewProject = useNewProject(close) + + const toggle = async (path: string) => { + const model = await invoke("read_to_model", { path }) + + if (model) { + setModelData(model) + setModelPath(path) + recentProjCache.add(path) + } + + close() + } + + return ( +
    + + + {visible && ( +
    + + + + +
    + +

    最近的项目

    + {recentProjCache.items.map(p => ( + + ))} +
    + )} +
    + ) +} + +type ProjectLineProps = { + path: string + toggle: (newPath: string) => void +} + +const RecentProj = ({ path, toggle }: ProjectLineProps) => { + const handleNew = () => toggle(path) + + return ( +
    + + + + + +
    + ) +} + +export default Project diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Search/SearchModal.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Search/SearchModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1396b53c21ca355d9bcc1366d51160c390b39d1e --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Search/SearchModal.tsx @@ -0,0 +1,200 @@ +import { useClickOutside } from "hooks" +import { useAtom, useAtomValue } from "jotai" +import { clsx } from "libs" +import { type ChangeEvent, useCallback, useEffect, useRef } from "react" +import { opSummaryAtom, translateAtom, candidatesAtom, queryAtom, searchVisibleAtom } from "stores" +import { FlowIcon, LayerIcon, SearchIcon } from "./icons" + +type OpProps = { + node: RenderNode +} + +const Op = ({ node: { id, y } }: OpProps) => { + const translate = useAtomValue(translateAtom) + + const startAnimation = useAnimatedTranslate({ x: translate.x, y: -y }, 500) + + return ( + + ) +} + +const OpSummary = () => { + const nodeGroups = useAtomValue(opSummaryAtom) + + return ( +
    + {Object.entries(nodeGroups).map(([group, nodes]) => ( +
    +

    {group}

    +
    + {nodes?.map(node => ( + + ))} +
    +
    + ))} +
    + ) +} + +type NodeProps = { + id: string + y: number +} + +const NodeCandidate = ({ id, y }: NodeProps) => { + const [name, op] = id.split("||") + + const translate = useAtomValue(translateAtom) + + const startAnimation = useAnimatedTranslate({ x: translate.x, y: -y }, 500) + + return ( +
    + {LayerIcon} +
    + {name} + {op} +
    +
    + ) +} + +const bezierProgress = (t: number, p1: number, p2: number) => + t * t * (3 - 2 * t) * p1 + t * (1 - t) * 2 * p2 + +const useAnimatedTranslate = (targetTranslate: Point, duration = 1000) => { + const [{ x, y }, setTranslate] = useAtom(translateAtom) + const isAnimating = useRef(false) + const animationTimeout = useRef(null) + + const startAnimation = () => { + isAnimating.current = true + const startTime = performance.now() + + const animate = (t: number) => { + if (!isAnimating.current) return + + const elapsed = t - startTime + const progress = elapsed / duration + const newX = x + (targetTranslate.x - x) * progress + const newY = y + (targetTranslate.y - y) * progress + + setTranslate({ x: newX, y: newY }) + + if (progress < 1) requestAnimationFrame(animate) + else isAnimating.current = false + } + + requestAnimationFrame(animate) + } + + useEffect( + () => () => { + isAnimating.current = false + if (animationTimeout.current) clearTimeout(animationTimeout.current) + }, + [] + ) + + return startAnimation +} + +type EdgeProps = { + source: string + target: string + pos: Point +} + +const EdgeCandidate = ({ source, target, pos }: EdgeProps) => { + const [hn, ho] = source.split("||") + const [tn, to] = target.split("||") + + // const [zoom] = useZoom() + const translate = useAtomValue(translateAtom) + + const startAnimation = useAnimatedTranslate({ x: translate.x, y: -pos.y }, 500) + + return ( +
    + {FlowIcon} +
    +
    + {hn} + {ho} +
    +
    + {tn} + {to} +
    +
    +
    + ) +} + +export const SearchModal = () => { + const [visible, setVisible] = useAtom(searchVisibleAtom) + const [query, setQuery] = useAtom(queryAtom) + const nodes = useAtomValue(candidatesAtom) + + const close = useCallback(() => setVisible(false), []) + + const ref = useClickOutside(close) + + const handleChange = (ev: ChangeEvent) => + setQuery(ev.target.value.toLowerCase()) + + return visible ? ( +
    +
    + + {SearchIcon} +
    +
    + {query ? ( +
    + {nodes.map(({ item: { id, y } }) => ( + + ))} + {/*{edges.map(({ item: { id, head, tail, ip } }) => (*/} + {/* */} + {/*))}*/} +
    + ) : ( + + )} +
    +
    + ) : ( + <> + ) +} diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Search/icons.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Search/icons.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0cacbcae3006d385dd675fc091ce3735531a0ce0 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Search/icons.tsx @@ -0,0 +1,20 @@ +export const SearchIcon = ( + + + +) + +export const LayerIcon = ( + + + +) + +export const FlowIcon = ( + + + +) diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Search/index.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Search/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ee1ae64eaf1623a9829ed6e4e85811c3c8d13a57 --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Search/index.tsx @@ -0,0 +1,40 @@ +import { searchVisibleAtom } from "stores" +import { useEffect } from "react" +import { useAtom } from "jotai" + +const Search = () => { + const [visible, setVisible] = useAtom(searchVisibleAtom) + + const handleClick = () => setVisible(true) + + useEffect(() => { + const handleCtrlK = (e: KeyboardEvent) => + (e.ctrlKey || e.metaKey) && e.key === "f" && !visible && setVisible(true) + document.addEventListener("keydown", handleCtrlK) + + return () => document.removeEventListener("keydown", handleCtrlK) + }, []) + + return ( + +) +} + +export default Search + +export * from "./SearchModal" diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Sidebar.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Sidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c620e4a3b94fa1741e06f977b9aa2bb0a21f156a --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Sidebar.tsx @@ -0,0 +1,49 @@ +import React, { type MouseEvent, type ReactNode, useState } from "react" + +type Props = { + width: number + children: ReactNode + onResize: (newWidth: number) => void +} + +export const Sidebar = ({ width, children, onResize }: Props) => { + const [dragging, setDragging] = useState(false) + const [startPageX, setStartPageX] = useState(0) + + const handleMouseDown = ({ pageX }: MouseEvent) => { + setDragging(true) + setStartPageX(pageX) + } + + const handleMouseMove = ({ pageX }: MouseEvent) => { + const currentSideBarWidth = width + pageX - startPageX + onResize(currentSideBarWidth) + setStartPageX(pageX) + } + + const handleMouseUp = () => { + setDragging(false) + localStorage.setItem("sideBarWidth", "" + width) + } + + return ( + <> +
    + {children} +
    +
    + {dragging && ( +
    + )} +
    + + ) +} diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/ThemeSwitch.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/ThemeSwitch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..98c913622954ac1e6dd94cca44d08322eb46762d --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/ThemeSwitch.tsx @@ -0,0 +1,146 @@ +import { clsx } from "libs" +import { motion } from "framer-motion" +import { useAtom } from "jotai" +import { useClickOutside, useI18n, useVisible } from "hooks" +import { type SVGProps, useEffect } from "react" +import { type Theme, THEME, themeAtom } from "stores" + +const System = (props: SVGProps) => ( + + + +) + +const Sun = (props: SVGProps) => ( + + + +) + +const Moon = (props: SVGProps) => ( + + + + +) + +const themes = [ + { + theme: THEME.LIGHT, + icon: Sun + }, + { + theme: THEME.DARK, + icon: Moon + }, + { + theme: THEME.SYSTEM, + icon: System + } +] + +const systemThemeListener = (event: MediaQueryListEvent) => { + const local = localStorage.getItem("theme") + const classList = document.documentElement.classList + const hasDark = classList.contains("dark") + if (local === THEME.SYSTEM) + if (event.matches && !hasDark) classList.add("dark") + else if (hasDark) classList.remove("dark") +} + +const ThemeSwitch = () => { + const { visible, open, close } = useVisible() + const [appTheme, setAppTheme] = useAtom(themeAtom) + const t = useI18n() + + const ref = useClickOutside(close) + + const animate = { x: ["-50%", "-50%"], y: ["-5%", "0%"], opacity: [0.3, 1] } + + useEffect(() => { + const preferredTheme = localStorage.getItem("theme") + const match = window.matchMedia("(prefers-color-scheme: dark)").matches + const classList = document.documentElement.classList + const hasDark = classList.contains("dark") + if (preferredTheme === THEME.DARK || (preferredTheme === THEME.SYSTEM && match && !hasDark)) + classList.add("dark") + }, []) + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + + mediaQuery.addEventListener("change", systemThemeListener) + + return () => mediaQuery.removeEventListener("change", systemThemeListener) + }, []) + + const handleThemeChange = (newTheme: Theme) => { + if (newTheme !== appTheme) { + setAppTheme(() => { + const classList = document.documentElement.classList + const hasDark = classList.contains("dark") + const match = window.matchMedia("(prefers-color-scheme: dark)").matches + + switch (newTheme) { + case THEME.LIGHT: + if (hasDark) classList.remove("dark") + break + case THEME.DARK: + if (!hasDark) classList.add("dark") + break + case THEME.SYSTEM: + if (match && !hasDark) classList.add("dark") + else if (!match && hasDark) classList.remove("dark") + break + } + + localStorage.setItem("theme", newTheme) + return newTheme + }) + close() + } + } + + return ( +
    + + {visible && ( + + {themes.map(({ theme, icon: Icon }) => ( + + ))} + + )} +
    + ) +} + +export default ThemeSwitch diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Toolbar.tsx b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Toolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d02f634ee4fd45af91628f628fc343ce3cefc94f --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/Toolbar.tsx @@ -0,0 +1,17 @@ +import LanguageSwitch from "./LanguageSwitch" +import ThemeSwitch from "./ThemeSwitch" +import Search from "./Search" +import Project from "./Project" + +export const Toolbar = () => ( +
    +
    + + +
    + + +
    +
    +
    +) diff --git a/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/index.ts b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1703814ec649702e63f0c3dd664486753d4d417c --- /dev/null +++ b/plugins/mindstudio-insight-plugins/ModelVis/app/src/features/index.ts @@ -0,0 +1,3 @@ +export * from "./NewProject" +export * from "./Toolbar" +export * from "./Search" \ No newline at end of file