diff --git a/site/package-lock.json b/site/package-lock.json index 9209b97..720f486 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -4559,33 +4559,6 @@ "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", "license": "MIT" }, - "node_modules/@reduxjs/toolkit": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.10.1.tgz", - "integrity": "sha512-/U17EXQ9Do9Yx4DlNGU6eVNfZvFJfYpUtRRdLf19PbPjdWBxNlxGZXywQZ1p1Nz8nMkWplTI7iD/23m07nolDA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^10.2.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -5580,26 +5553,6 @@ "@xtuc/long": "4.2.2" } }, - "node_modules/@xstate/react": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@xstate/react/-/react-5.0.5.tgz", - "integrity": "sha512-MfF/cPHa3lNKJmGFpUycMbNP25qBXyZXrxc8VYNroAu0Nnk0DV5WzAkTcQXma0xEC4dSwsoA+YQuKbZATtqvgg==", - "license": "MIT", - "peer": true, - "dependencies": { - "use-isomorphic-layout-effect": "^1.1.2", - "use-sync-external-store": "^1.2.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "xstate": "^5.19.4" - }, - "peerDependenciesMeta": { - "xstate": { - "optional": true - } - } - }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -19041,20 +18994,6 @@ "is-typedarray": "^1.0.0" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/ua-parser-js": { "version": "1.0.41", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", diff --git a/site/src/components/ShapeBuilder/index.js b/site/src/components/ShapeBuilder/index.js index 5ab1d15..1e9cd48 100644 --- a/site/src/components/ShapeBuilder/index.js +++ b/site/src/components/ShapeBuilder/index.js @@ -1,30 +1,22 @@ -// /* global window */ import React, { useEffect, useRef, useState } from "react"; -import { Wrapper, CanvasContainer, OutputBox, StyledSVG, CopyButton } from "./shapeBuilder.styles"; -import { Button, Typography, Box, CopyIcon, Select, MenuItem, Slider, FormControl } from "@sistent/sistent"; -import { SVG, extend as SVGextend } from "@svgdotjs/svg.js"; -import draw from "@svgdotjs/svg.draw.js"; +import { Wrapper, CanvasContainer, OutputBox, StyledSVG } from "./shapeBuilder.styles"; +import { Button, Typography, Box } from "@sistent/sistent"; -SVGextend(SVG.Polygon, draw); +const defaultStroke = "#00B39F"; -const SCALE_PRESETS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3]; -const MIN_SCALE = 0.1; -const MAX_SCALE = 3; +function getSvgPoint(svg, clientX, clientY) { + if (!svg) return { x: clientX, y: clientY }; + const pt = svg.createSVGPoint(); + pt.x = clientX; + pt.y = clientY; + return pt.matrixTransform(svg.getScreenCTM().inverse()); +} const ShapeBuilder = () => { - const boardRef = useRef(null); - const polyRef = useRef(null); - const keyHandlersRef = useRef({}); - const basePointsRef = useRef(null); - const [result, setResult] = useState(""); - const [error, setError] = useState(null); const [showCopied, setShowCopied] = useState(false); - const [scale, setScale] = useState(1); - const [currentPreset, setCurrentPreset] = useState(1); const handleCopyToClipboard = async () => { if (!result.trim()) return; - try { await navigator.clipboard.writeText(result); setShowCopied(true); @@ -33,187 +25,394 @@ const ShapeBuilder = () => { console.error("Failed to copy to clipboard:", err); } }; + const boardRef = useRef(null); + const [mousePoint, setMousePoint] = useState(null); + const [nearFirst, setNearFirst] = useState(false); + const [anchors, setAnchors] = useState([]); // {x,y, handleIn:{x,y}, handleOut:{x,y}} + const [isClosed, setIsClosed] = useState(false); + const [dragState, setDragState] = useState(null); + const [result, setResult] = useState(""); - const getPlottedPoints = (poly) => { - if (!poly) return null; - const plotted = poly.plot(); - const points = Array.isArray(plotted) ? plotted : plotted?.value; - return Array.isArray(points) ? points : null; + // deep clone anchors helper + const cloneAnchors = (arr) => arr.map(a => ({ + x: a.x, y: a.y, + handleIn: { x: a.handleIn.x, y: a.handleIn.y }, + handleOut: { x: a.handleOut.x, y: a.handleOut.y } + })); + + // Add a new anchor and optionally begin placing (dragging handle) + const addAnchor = (x, y, placing = true) => { + const newAnchor = { x, y, handleIn: { x, y }, handleOut: { x, y } }; + setAnchors(prev => { + const next = cloneAnchors(prev); + next.push(newAnchor); + return next; + }); + + if (placing) { + // index will be previous length + setDragState(prev => ({ type: "placing", index: (anchors.length), start: { x, y } })); + } + }; + + const updateAnchorHandle = (index, handleKey, hx, hy, symmetric = true) => { + setAnchors(prev => { + const next = cloneAnchors(prev); + if (!next[index]) return prev; + next[index][handleKey] = { x: hx, y: hy }; + if (symmetric) { + const ax = next[index].x; + const ay = next[index].y; + const dx = hx - ax; + const dy = hy - ay; + const opposite = handleKey === "handleOut" ? "handleIn" : "handleOut"; + next[index][opposite] = { x: ax - dx, y: ay - dy }; + } + return next; + }); }; - const showCytoArray = () => { - const poly = polyRef.current; - if (!poly) return; + const updatePathOnMove = (clientX, clientY) => { + if (!boardRef.current) return; + const pt = getSvgPoint(boardRef.current, clientX, clientY); + if (!dragState) return; - try { - const points = getPlottedPoints(poly); - if (!points) throw new Error("Invalid or empty polygon points"); - - const normalized = points - .map(([x, y]) => [(x - 260) / 260, (y - 260) / 260]) - .flat() - .join(" "); - setResult(normalized); - setError(null); - } catch (err) { - setError("Failed to extract and normalize polygon points."); - console.error("showCytoArray error:", err); + if (dragState.type === "placing") { + updateAnchorHandle(dragState.index, "handleOut", pt.x, pt.y, true); + } else if (dragState.type === "handle") { + updateAnchorHandle(dragState.index, dragState.handleKey, pt.x, pt.y, dragState.symmetric); } }; - const applyScale = (newScale) => { - const poly = polyRef.current; - if (!poly) return; + // Mouse handlers + const onMouseDown = (e) => { + // left button only + if (e.button !== 0) return; + if (isClosed) return; + + const pt = getSvgPoint(boardRef.current, e.clientX, e.clientY); + + // If we're near the first point and already have a polygon, auto-close + if (anchors.length >= 3) { + const first = anchors[0]; + const dx = pt.x - first.x; + const dy = pt.y - first.y; + const distanceSq = dx * dx + dy * dy; + const threshold = 6; // tighter pixels radius for snapping/closing + + if (distanceSq <= threshold * threshold) { + setIsClosed(true); + setDragState(null); + return; + } + } - const points = getPlottedPoints(poly); - if (!points || points.length === 0) return; + // Otherwise, create a new anchor + addAnchor(pt.x, pt.y, true); + }; - if (!basePointsRef.current) { - basePointsRef.current = points; + const onMouseMove = (e) => { + // update preview point + if (!boardRef.current) return; + const pt = getSvgPoint(boardRef.current, e.clientX, e.clientY); + + // detect proximity to first anchor for hover-close indication + if (!isClosed && anchors.length >= 3) { + const first = anchors[0]; + const dx = pt.x - first.x; + const dy = pt.y - first.y; + const distanceSq = dx * dx + dy * dy; + const threshold = 6; // tighter radius in pixels + setNearFirst(distanceSq <= threshold * threshold); + } else { + setNearFirst(false); } - const basePoints = basePointsRef.current; - - const xs = basePoints.map(p => p[0]); - const ys = basePoints.map(p => p[1]); - const centerX = (Math.max(...xs) + Math.min(...xs)) / 2; - const centerY = (Math.max(...ys) + Math.min(...ys)) / 2; + setMousePoint(pt); + if (dragState) { + updatePathOnMove(e.clientX, e.clientY); + } + }; - const scaledPoints = basePoints.map(([x, y]) => { - const dx = x - centerX; - const dy = y - centerY; - return [centerX + dx * newScale, centerY + dy * newScale]; - }); + const onMouseUp = (e) => { + // finalize placing/dragging + setDragState(null); + }; - poly.plot(scaledPoints); - showCytoArray(); + const onHandleMouseDown = (e, index, handleKey) => { + e.stopPropagation(); + const symmetric = !e.shiftKey; // shift decouples handles + setDragState({ type: "handle", index, handleKey, symmetric }); }; - const handleScaleChange = (newScale) => { - const clampedScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale)); - setScale(clampedScale); + const onAnchorMouseDown = (e, index) => { + e.stopPropagation(); - const matchingPreset = SCALE_PRESETS.find(p => Math.abs(p - clampedScale) < 0.01); - setCurrentPreset(matchingPreset || clampedScale); + // If we click the first point while drawing (and have enough anchors), close the shape instead of moving it + if (!isClosed && index === 0 && anchors.length >= 3) { + setIsClosed(true); + setDragState(null); + return; + } - applyScale(clampedScale); + // Otherwise, start moving this anchor + const start = getSvgPoint(boardRef.current, e.clientX, e.clientY); + setDragState({ type: "moveAnchor", index, start }); }; - const handlePresetChange = (event) => { - const newPreset = event.target.value; - setCurrentPreset(newPreset); - setScale(newPreset); - applyScale(newPreset); - }; + // move anchor effect + useEffect(() => { + if (!dragState || dragState.type !== "moveAnchor") return; + + const move = (ev) => { + const pt = getSvgPoint(boardRef.current, ev.clientX, ev.clientY); + setAnchors(prev => { + const next = cloneAnchors(prev); + const idx = dragState.index; + if (!next[idx]) return prev; + const dx = pt.x - dragState.start.x; + const dy = pt.y - dragState.start.y; + next[idx].x += dx; next[idx].y += dy; + next[idx].handleIn.x += dx; next[idx].handleIn.y += dy; + next[idx].handleOut.x += dx; next[idx].handleOut.y += dy; + return next; + }); + setDragState(s => ({ ...s, start: pt })); + }; - const handleSliderChange = (event, newValue) => { - handleScaleChange(newValue); - }; + const up = () => setDragState(null); + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + return () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + }; + }, [dragState]); - const handleKeyDown = (e) => { - const poly = polyRef.current; - if (!poly) return; + // global handle/placing drag listeners + useEffect(() => { + if (!dragState) return; + if (dragState.type !== "handle" && dragState.type !== "placing") return; - if (e.ctrlKey) { - poly.draw("param", "snapToGrid", 0.001); - } + const onMove = (ev) => updatePathOnMove(ev.clientX, ev.clientY); + const onUp = () => setDragState(null); + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + }, [dragState]); - if (e.key === "Enter" || e.key === "Escape") { - poly.draw("done"); - poly.fill("#00B39F"); - showCytoArray(); + // keyboard handlers + useEffect(() => { + const onKeyDown = (e) => { + if (e.key === "Enter" && anchors.length >= 3) { + setIsClosed(true); + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { + setAnchors(prev => prev.slice(0, -1)); + setIsClosed(false); + } + if (e.key === "Escape") { + // Close shape on ESC + if (anchors.length >= 3) { + setIsClosed(true); + } + setDragState(null); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [anchors]); + + const buildPathD = () => { + if (anchors.length === 0) return ""; + let d = `M ${anchors[0].x} ${anchors[0].y}`; + for (let i = 1; i < anchors.length; i++) { + const prev = anchors[i - 1]; + const curr = anchors[i]; + d += ` C ${prev.handleOut.x} ${prev.handleOut.y}, ${curr.handleIn.x} ${curr.handleIn.y}, ${curr.x} ${curr.y}`; } - - if (e.ctrlKey && e.key.toLowerCase() === "z") { - const points = getPlottedPoints(poly); - if (!points) return; - poly.plot(points.slice(0, -1)); + if (isClosed && anchors.length >= 2) { + const last = anchors[anchors.length - 1]; + const first = anchors[0]; + d += ` C ${last.handleOut.x} ${last.handleOut.y}, ${first.handleIn.x} ${first.handleIn.y}, ${first.x} ${first.y} Z`; } + return d; }; - const handleKeyUp = (e) => { - const poly = polyRef.current; - if (!poly || e.ctrlKey) return; - poly.draw("param", "snapToGrid", 16); + // ---- Cytoscape-compatible export ---- + // Adaptive Bezier flattening (flatness-based) + // Produces far fewer points while preserving visual accuracy for Cytoscape + + const FLATNESS_TOLERANCE = 0.5; // px; increase to reduce points further + + const distPointToLineSq = (px, py, x1, y1, x2, y2) => { + const A = px - x1; + const B = py - y1; + const C = x2 - x1; + const D = y2 - y1; + + const dot = A * C + B * D; + const lenSq = C * C + D * D || 1; + const param = dot / lenSq; + + const xx = x1 + param * C; + const yy = y1 + param * D; + + const dx = px - xx; + const dy = py - yy; + return dx * dx + dy * dy; }; - const attachKeyListeners = () => { - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("keyup", handleKeyUp); - keyHandlersRef.current = { handleKeyDown, handleKeyUp }; + const isFlatEnough = (p0, p1, p2, p3, tolSq) => { + const d1 = distPointToLineSq(p1.x, p1.y, p0.x, p0.y, p3.x, p3.y); + const d2 = distPointToLineSq(p2.x, p2.y, p0.x, p0.y, p3.x, p3.y); + return d1 <= tolSq && d2 <= tolSq; }; - const detachKeyListeners = () => { - const { handleKeyDown, handleKeyUp } = keyHandlersRef.current; - if (handleKeyDown) document.removeEventListener("keydown", handleKeyDown); - if (handleKeyUp) document.removeEventListener("keyup", handleKeyUp); - keyHandlersRef.current = {}; + const subdivideBezier = (p0, p1, p2, p3) => { + const p01 = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 }; + const p12 = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; + const p23 = { x: (p2.x + p3.x) / 2, y: (p2.y + p3.y) / 2 }; + + const p012 = { x: (p01.x + p12.x) / 2, y: (p01.y + p12.y) / 2 }; + const p123 = { x: (p12.x + p23.x) / 2, y: (p12.y + p23.y) / 2 }; + + const p0123 = { x: (p012.x + p123.x) / 2, y: (p012.y + p123.y) / 2 }; + + return [ + [p0, p01, p012, p0123], + [p0123, p123, p23, p3] + ]; }; - const initializeDrawing = () => { - if (!boardRef.current) { - setError("Canvas reference not found"); - return; + const flattenBezierAdaptive = (p0, p1, p2, p3, tolSq, out) => { + if (isFlatEnough(p0, p1, p2, p3, tolSq)) { + out.push([p3.x, p3.y]); + } else { + const [l, r] = subdivideBezier(p0, p1, p2, p3); + flattenBezierAdaptive(l[0], l[1], l[2], l[3], tolSq, out); + flattenBezierAdaptive(r[0], r[1], r[2], r[3], tolSq, out); } + }; - try { - const draw = SVG() - .addTo(boardRef.current) - .size("100%", "100%") - .polygon() - .draw() - .attr({ stroke: "#00B39F", "stroke-width": 1, fill: "none" }); - - draw.draw("param", "snapToGrid", 16); - draw.on("drawstart", attachKeyListeners); - draw.on("drawdone", detachKeyListeners); - - polyRef.current = draw; - setError(null); - } catch (err) { - setError(`Failed to initialize drawing: ${err.message}`); + const flattenToPoints = () => { + if (anchors.length === 0) return []; + + const pts = []; + const tolSq = FLATNESS_TOLERANCE * FLATNESS_TOLERANCE; + + // start point + pts.push([anchors[0].x, anchors[0].y]); + + for (let i = 1; i < anchors.length; i++) { + const prev = anchors[i - 1]; + const curr = anchors[i]; + flattenBezierAdaptive( + { x: prev.x, y: prev.y }, + { x: prev.handleOut.x, y: prev.handleOut.y }, + { x: curr.handleIn.x, y: curr.handleIn.y }, + { x: curr.x, y: curr.y }, + tolSq, + pts + ); } - }; - const clearShape = () => { - const poly = polyRef.current; - if (!poly) return; + if (isClosed && anchors.length >= 2) { + const last = anchors[anchors.length - 1]; + const first = anchors[0]; + flattenBezierAdaptive( + { x: last.x, y: last.y }, + { x: last.handleOut.x, y: last.handleOut.y }, + { x: first.handleIn.x, y: first.handleIn.y }, + { x: first.x, y: first.y }, + tolSq, + pts + ); + } - poly.draw("cancel"); - poly.remove(); - detachKeyListeners(); - polyRef.current = null; - basePointsRef.current = null; - setResult(""); - setScale(1); - setCurrentPreset(1); - initializeDrawing(); + return pts; }; - const closeShape = () => { - const poly = polyRef.current; - if (!poly) return; + const normalizePoints = (pts) => { + if (!pts.length) return []; - poly.draw("done"); - poly.fill("#00B39F"); - const points = getPlottedPoints(poly); - if (points && points.length > 0) { - basePointsRef.current = points; - } - showCytoArray(); + const xs = pts.map(p => p[0]); + const ys = pts.map(p => p[1]); + + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const size = Math.max(maxX - minX, maxY - minY) || 1; + + return pts.map(([x, y]) => [ + Number(((x - cx) * 2 / size).toFixed(4)), + Number(((y - cy) * 2 / size).toFixed(4)) + ]); + }; + + const computeExportString = () => { + const flat = flattenToPoints(); + const normalized = normalizePoints(flat); + return normalized.flat().join(" "); }; useEffect(() => { - initializeDrawing(); - return () => { - detachKeyListeners(); - if (polyRef.current) { - polyRef.current.draw("cancel"); - polyRef.current.remove(); - polyRef.current = null; + setResult(computeExportString()); + }, [anchors, isClosed]); + + // Maximize: scale + translate shape to fit 520x520 + const maximize = () => { + if (anchors.length === 0) return; + + const xs = []; + const ys = []; + anchors.forEach(a => { + xs.push(a.x, a.handleIn.x, a.handleOut.x); + ys.push(a.y, a.handleIn.y, a.handleOut.y); + }); + + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + + const width = maxX - minX; + const height = maxY - minY; + if (width === 0 || height === 0) return; + + const target = 520; + const scale = Math.min(target / width, target / height); + + const offsetX = -minX; + const offsetY = -minY; + + setAnchors(prev => prev.map(a => ({ + x: (a.x + offsetX) * scale, + y: (a.y + offsetY) * scale, + handleIn: { + x: (a.handleIn.x + offsetX) * scale, + y: (a.handleIn.y + offsetY) * scale + }, + handleOut: { + x: (a.handleOut.x + offsetX) * scale, + y: (a.handleOut.y + offsetY) * scale } - }; - }, []); + }))); + }; + + const clear = () => { + setAnchors([]); + setIsClosed(false); + setDragState(null); + setResult(""); + }; return ( @@ -222,99 +421,112 @@ const ShapeBuilder = () => { ref={boardRef} width="100%" height="100%" - onDoubleClick={closeShape} + onMouseDown={onMouseDown} + onMouseMove={onMouseMove} + onMouseUp={onMouseUp} + onDoubleClick={() => { + if (!isClosed && anchors.length >= 3) setIsClosed(true); + }} > + - - {error && ( -
- {error} -
- )} - + + + {/* path preview */} + + + {/* preview mouse point */} + {nearFirst && anchors.length > 0 && !isClosed && ( + // highlight halo around first point when close enough to auto-close + + )} - - - - - - - - - - - `${value.toFixed(2)}×`} - marks={SCALE_PRESETS.map(value => ({ value, label: "" }))} - aria-label="Scale slider" - sx={{ flexGrow: 1 }} + {mousePoint && anchors.length > 0 && !isClosed && ( + - + )} + + {mousePoint && !isClosed && ( + + )} + + {/* anchors, handles */} + {anchors.map((a, idx) => ( + + + + + onHandleMouseDown(e, idx, "handleIn")} + /> + + onHandleMouseDown(e, idx, "handleOut")} + /> + + onAnchorMouseDown(e, idx)} + /> + + ))} + + - - {scale.toFixed(2)}× - - + + + + - Polygon Coordinates (SVG format): + SVG Path (d attribute): -
-