From 9e32bd6faa64c24f498c5a27a3e783d08e2d1c30 Mon Sep 17 00:00:00 2001 From: Jeet Burman Date: Sat, 20 Dec 2025 13:26:31 +0530 Subject: [PATCH 1/5] feat: add live mouse coordinate display to shape builder canvas - Add real-time coordinate tracking on mouse movement - Display normalized coordinates (-1 to 1 range) matching polygon output - Show pixel coordinates for reference - Coordinate display only visible when mouse hovers over canvas - Theme-aware styling adapts to light/dark modes - Non-intrusive positioning in top-right corner - Smooth show/hide transitions on mouse enter/leave Fixes #95 --- site/src/components/ShapeBuilder/index.js | 64 ++++++++++++++++++- .../ShapeBuilder/shapeBuilder.styles.js | 49 ++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/site/src/components/ShapeBuilder/index.js b/site/src/components/ShapeBuilder/index.js index ecbc32a..e4d90df 100644 --- a/site/src/components/ShapeBuilder/index.js +++ b/site/src/components/ShapeBuilder/index.js @@ -1,6 +1,6 @@ // /* global window */ import React, { useEffect, useRef, useState } from "react"; -import { Wrapper, CanvasContainer, OutputBox, StyledSVG, CopyButton } from "./shapeBuilder.styles"; +import { Wrapper, CanvasContainer, OutputBox, StyledSVG, CopyButton, CoordinateDisplay } 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"; @@ -23,6 +23,9 @@ const ShapeBuilder = () => { const [scale, setScale] = useState(1); const [currentPreset, setCurrentPreset] = useState(1); + const [mouseCoords, setMouseCoords] = useState({ x: 0, y: 0, normalized: { x: 0, y: 0 } }); + const [isMouseInCanvas, setIsMouseInCanvas] = useState(false); + const handleCopyToClipboard = async () => { if (!result.trim()) return; @@ -89,6 +92,36 @@ const ShapeBuilder = () => { poly.plot(scaledPoints); showCytoArray(); }; + + const handleMouseMove = (e) => { + const svg = boardRef.current; + if (!svg) return; + + const rect = svg.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const centerX = rect.width / 2; + const centerY = rect.height / 2; + const normalizedX = (x - centerX) / centerX; + const normalizedY = (y - centerY) / centerY; + + setMouseCoords({ + x: Math.round(x), + y: Math.round(y), + normalized: { + x: parseFloat(normalizedX.toFixed(3)), + y: parseFloat(normalizedY.toFixed(3)) + } + }); +}; + + const handleMouseEnter = () => { + setIsMouseInCanvas(true); + }; + + const handleMouseLeave = () => { + setIsMouseInCanvas(false); + }; const handleScaleChange = (newScale) => { const clampedScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale)); @@ -225,6 +258,9 @@ const ShapeBuilder = () => { width="100%" height="100%" onDoubleClick={closeShape} + onMouseMove={handleMouseMove} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} > @@ -233,6 +269,32 @@ const ShapeBuilder = () => { + + {isMouseInCanvas && ( + +
Mouse Position
+
+
+ X: + {mouseCoords.normalized.x} +
+
+ Y: + {mouseCoords.normalized.y} +
+
+
+ Pixel: ({mouseCoords.x}, {mouseCoords.y}) +
+
+ )} + {error && (
theme?.mode === "light" ? "rgba(255, 255, 255, 0.95)" : "rgba(43, 43, 43, 0.95)"}; + border: 2px solid #00B39F; + border-radius: 8px; + padding: 12px 16px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 14px; + font-weight: 600; + color: ${({ theme }) => theme?.mode === "light" ? "#111" : "#fff"}; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + z-index: 10; + user-select: none; + min-width: 150px; + + .coordinate-label { + color: #00B39F; + font-size: 12px; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .coordinate-values { + display: flex; + gap: 16px; + margin-top: 8px; + } + + .coordinate-item { + display: flex; + flex-direction: column; + gap: 2px; + } + + .axis-label { + font-size: 11px; + color: ${({ theme }) => theme?.mode === "light" ? "#666" : "#aaa"}; + } + + .axis-value { + font-size: 16px; + color: ${({ theme }) => theme?.mode === "light" ? "#111" : "#fff"}; + font-weight: 700; + } +`; + // export const Wrapper = styled.div` // padding: 2rem; // background-color: ${({ theme }) => theme.palette.background.default}; From 1b7f74fa9fb223b4b3f02a885649b2de1ad8319d Mon Sep 17 00:00:00 2001 From: Jeet Burman Date: Tue, 23 Dec 2025 18:42:15 +0530 Subject: [PATCH 2/5] refactor: make coordinate display follow cursor - Coordinate display now follows mouse cursor instead of fixed position - Removed pixel coordinates, showing only normalized X,Y values - Changed from fixed top-right position to cursor-relative positioning - Uses SVG-relative coordinates (x,y) instead of screen coordinates - Maintains smooth 15px offset to prevent blocking cursor - Simplified to single-line format: 'X: 0.234, Y: -0.567' Based on team feedback from code review --- site/src/components/ShapeBuilder/index.js | 77 ++++++++----------- .../ShapeBuilder/shapeBuilder.styles.js | 47 ++--------- 2 files changed, 41 insertions(+), 83 deletions(-) diff --git a/site/src/components/ShapeBuilder/index.js b/site/src/components/ShapeBuilder/index.js index e4d90df..e8409b2 100644 --- a/site/src/components/ShapeBuilder/index.js +++ b/site/src/components/ShapeBuilder/index.js @@ -94,26 +94,29 @@ const ShapeBuilder = () => { }; const handleMouseMove = (e) => { - const svg = boardRef.current; - if (!svg) return; - - const rect = svg.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const centerX = rect.width / 2; - const centerY = rect.height / 2; - const normalizedX = (x - centerX) / centerX; - const normalizedY = (y - centerY) / centerY; - - setMouseCoords({ - x: Math.round(x), - y: Math.round(y), - normalized: { - x: parseFloat(normalizedX.toFixed(3)), - y: parseFloat(normalizedY.toFixed(3)) - } - }); -}; + const svg = boardRef.current; + if (!svg) return; + + const rect = svg.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const centerX = rect.width / 2; + const centerY = rect.height / 2; + const normalizedX = (x - centerX) / centerX; + const normalizedY = (y - centerY) / centerY; + + setMouseCoords({ + x: Math.round(x), + y: Math.round(y), + normalized: { + x: parseFloat(normalizedX.toFixed(3)), + y: parseFloat(normalizedY.toFixed(3)) + }, + + screenX: e.clientX, + screenY: e.clientY + }); + }; const handleMouseEnter = () => { setIsMouseInCanvas(true); @@ -270,30 +273,16 @@ const ShapeBuilder = () => { - {isMouseInCanvas && ( - -
Mouse Position
-
-
- X: - {mouseCoords.normalized.x} -
-
- Y: - {mouseCoords.normalized.y} -
-
-
- Pixel: ({mouseCoords.x}, {mouseCoords.y}) -
-
- )} + {isMouseInCanvas && ( + + X: {mouseCoords.normalized.x}, Y: {mouseCoords.normalized.y} + + )} {error && (
theme?.mode === "light" ? "rgba(255, 255, 255, 0.95)" : "rgba(43, 43, 43, 0.95)"}; border: 2px solid #00B39F; - border-radius: 8px; - padding: 12px 16px; + border-radius: 6px; + padding: 6px 10px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; - font-size: 14px; + font-size: 12px; font-weight: 600; color: ${({ theme }) => theme?.mode === "light" ? "#111" : "#fff"}; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - z-index: 10; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + z-index: 1000; user-select: none; - min-width: 150px; - - .coordinate-label { - color: #00B39F; - font-size: 12px; - margin-bottom: 4px; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .coordinate-values { - display: flex; - gap: 16px; - margin-top: 8px; - } - - .coordinate-item { - display: flex; - flex-direction: column; - gap: 2px; - } - - .axis-label { - font-size: 11px; - color: ${({ theme }) => theme?.mode === "light" ? "#666" : "#aaa"}; - } - - .axis-value { - font-size: 16px; - color: ${({ theme }) => theme?.mode === "light" ? "#111" : "#fff"}; - font-weight: 700; - } + white-space: nowrap; + transition: left 0.05s ease-out, top 0.05s ease-out; `; // export const Wrapper = styled.div` From 0cb5e35b13301e6916f215c807062044d4d24ab4 Mon Sep 17 00:00:00 2001 From: Jeet Burman Date: Thu, 25 Dec 2025 14:15:58 +0530 Subject: [PATCH 3/5] Removed Transitional delay --- site/src/components/ShapeBuilder/shapeBuilder.styles.js | 1 - 1 file changed, 1 deletion(-) diff --git a/site/src/components/ShapeBuilder/shapeBuilder.styles.js b/site/src/components/ShapeBuilder/shapeBuilder.styles.js index 90f87ab..f65882d 100644 --- a/site/src/components/ShapeBuilder/shapeBuilder.styles.js +++ b/site/src/components/ShapeBuilder/shapeBuilder.styles.js @@ -153,7 +153,6 @@ export const CoordinateDisplay = styled.div` z-index: 1000; user-select: none; white-space: nowrap; - transition: left 0.05s ease-out, top 0.05s ease-out; `; // export const Wrapper = styled.div` From 5d15b730ff4511de53a2aa6383e50a579159b414 Mon Sep 17 00:00:00 2001 From: Jeet Burman Date: Thu, 25 Dec 2025 14:51:17 +0530 Subject: [PATCH 4/5] feat: add toggle button to enable/disable coordinate display - Added showCoordinates state (default: true) - Added toggle button in toolbar with contained variant - Button label updates dynamically between Hide/Show Coordinates - Coordinates only display when both hovering and toggle enabled - Preserves all existing functionality --- site/src/components/ShapeBuilder/index.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/site/src/components/ShapeBuilder/index.js b/site/src/components/ShapeBuilder/index.js index e8409b2..b0dbefe 100644 --- a/site/src/components/ShapeBuilder/index.js +++ b/site/src/components/ShapeBuilder/index.js @@ -25,6 +25,7 @@ const ShapeBuilder = () => { const [mouseCoords, setMouseCoords] = useState({ x: 0, y: 0, normalized: { x: 0, y: 0 } }); const [isMouseInCanvas, setIsMouseInCanvas] = useState(false); + const [showCoordinates, setShowCoordinates] = useState(true); const handleCopyToClipboard = async () => { if (!result.trim()) return; @@ -147,6 +148,10 @@ const ShapeBuilder = () => { handleScaleChange(newValue); }; + const toggleCoordinates = () => { + setShowCoordinates(prev => !prev); + }; + const handleKeyDown = (e) => { const poly = polyRef.current; if (!poly) return; @@ -273,7 +278,7 @@ const ShapeBuilder = () => { - {isMouseInCanvas && ( + {isMouseInCanvas && showCoordinates && ( { + From b2ca073e659c3cb6b8d11e3e996394fa2802edcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20R=C3=AEo=20Silva?= <209376648+carlosriosilva@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:48:45 -0500 Subject: [PATCH 5/5] fix(shape-builder): make coordinate readout cross-browser, Sistent-themed and on-brand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the live mouse coordinate display. Browser compatibility: - Swap the mouse-only handlers for Pointer Events, so the readout works for mouse, pen and touch through one standard API. Handle pointercancel as well as pointerleave: a touch stream taken over by the browser never emits a leave and previously stranded the readout on screen. - Measure from e.currentTarget rather than a ref, so the rect always belongs to the element the handler is bound to, including when the event bubbles up from a drawn shape. - Add -webkit-user-select alongside user-select; WebKit still needs the prefix. - Anchor the readout to the near edges with right/bottom in the far quadrants so it is no longer clipped at the right and bottom of the canvas. - Coalesce pointer updates onto one animation frame instead of re-rendering once per event (60 events now produce 2 DOM updates, still showing the latest position), and cancel any pending frame on unmount. Sistent theming: - Replace the hardcoded colors, radius, padding, shadow and z-index with Sistent tokens: lightModePalette/darkModePalette for surface, text and border, and the Sistent theme scale for spacing, shape, shadows and the tooltip layer. Typography: - Render the readout in "Qanelas Soft" with no fallback stack, matching the family name declared in src/fonts.css and used by src/styles/styles.js, and take size, weight and line height from the Sistent textL1Bold scale. Add tabular-nums and fixed 3-decimal formatting so the readout no longer twitches as digit widths change. Also: fix the 13 eslint errors the feature introduced, drop the unused screenX/screenY state fields, collapse the two pieces of readout state into one nullable object so position and visibility cannot disagree, mark the pointer-only overlay aria-hidden, and give the toggle button aria-pressed. Signed-off-by: Carlos Rîo Silva <209376648+carlosriosilva@users.noreply.github.com> --- site/src/components/ShapeBuilder/index.js | 143 ++++++++++++------ .../ShapeBuilder/shapeBuilder.styles.js | 56 +++++-- 2 files changed, 144 insertions(+), 55 deletions(-) diff --git a/site/src/components/ShapeBuilder/index.js b/site/src/components/ShapeBuilder/index.js index b0dbefe..6f05baf 100644 --- a/site/src/components/ShapeBuilder/index.js +++ b/site/src/components/ShapeBuilder/index.js @@ -12,6 +12,43 @@ const MIN_SCALE = 0.1; const MAX_SCALE = 3; const MIN_POLYGON_POINTS = 3; +// Gap in px kept between the pointer and the coordinate readout. +const READOUT_GAP = 16; +// Decimal places shown in the readout. +const READOUT_PRECISION = 3; + +/* + * Maps a point in canvas pixels onto -1..1 relative to the canvas centre. + * Note this is measured off the live element, whereas `showCytoArray` still + * normalizes against a hardcoded 260px half-extent; the two agree only while + * the canvas is 520px square, which it is not at most viewport widths. That + * hardcoded divisor predates this feature and is left for a separate change so + * the polygon output contract is not altered here. + */ +const normalizeToCanvas = (x, y, rect) => [ + (x - rect.width / 2) / (rect.width / 2), + (y - rect.height / 2) / (rect.height / 2) +]; + +/* + * Anchors the readout to whichever pair of container edges keeps it on screen. + * Anchoring the far side with `right`/`bottom` means the readout never has to be + * measured to know it will not be clipped near the canvas edge. + */ +const buildReadoutAnchor = (x, y, rect) => { + const anchor = x > rect.width / 2 + ? { right: `${Math.round(rect.width - x + READOUT_GAP)}px` } + : { left: `${Math.round(x + READOUT_GAP)}px` }; + + if (y > rect.height / 2) { + anchor.bottom = `${Math.round(rect.height - y + READOUT_GAP)}px`; + } else { + anchor.top = `${Math.round(y + READOUT_GAP)}px`; + } + + return anchor; +}; + const ShapeBuilder = () => { const boardRef = useRef(null); const polyRef = useRef(null); @@ -23,9 +60,12 @@ const ShapeBuilder = () => { const [scale, setScale] = useState(1); const [currentPreset, setCurrentPreset] = useState(1); - const [mouseCoords, setMouseCoords] = useState({ x: 0, y: 0, normalized: { x: 0, y: 0 } }); - const [isMouseInCanvas, setIsMouseInCanvas] = useState(false); + // `null` whenever the pointer is off the canvas, so position and visibility + // can never disagree. + const [readout, setReadout] = useState(null); const [showCoordinates, setShowCoordinates] = useState(true); + const readoutFrameRef = useRef(0); + const pendingReadoutRef = useRef(null); const handleCopyToClipboard = async () => { if (!result.trim()) return; @@ -93,38 +133,51 @@ const ShapeBuilder = () => { poly.plot(scaledPoints); showCytoArray(); }; - - const handleMouseMove = (e) => { - const svg = boardRef.current; - if (!svg) return; - const rect = svg.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const centerX = rect.width / 2; - const centerY = rect.height / 2; - const normalizedX = (x - centerX) / centerX; - const normalizedY = (y - centerY) / centerY; - - setMouseCoords({ - x: Math.round(x), - y: Math.round(y), - normalized: { - x: parseFloat(normalizedX.toFixed(3)), - y: parseFloat(normalizedY.toFixed(3)) - }, - - screenX: e.clientX, - screenY: e.clientY + const cancelReadoutFrame = () => { + if (readoutFrameRef.current) { + window.cancelAnimationFrame(readoutFrameRef.current); + readoutFrameRef.current = 0; + } + pendingReadoutRef.current = null; + }; + + // Pointer events fire faster than the browser paints, so coalesce them onto a + // single animation frame rather than re-rendering once per event. + const scheduleReadout = (next) => { + pendingReadoutRef.current = next; + if (readoutFrameRef.current) return; + + readoutFrameRef.current = window.requestAnimationFrame(() => { + readoutFrameRef.current = 0; + setReadout(pendingReadoutRef.current); }); }; - const handleMouseEnter = () => { - setIsMouseInCanvas(true); + // Pointer events cover mouse, pen and touch with one standard API supported by + // every current browser; `currentTarget` is always the canvas the handler is + // bound to, even when the event bubbles up from a drawn shape. + const handlePointerMove = (e) => { + const rect = e.currentTarget.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const [normalizedX, normalizedY] = normalizeToCanvas(x, y, rect); + + scheduleReadout({ + anchor: buildReadoutAnchor(x, y, rect), + x: normalizedX.toFixed(READOUT_PRECISION), + y: normalizedY.toFixed(READOUT_PRECISION) + }); }; - const handleMouseLeave = () => { - setIsMouseInCanvas(false); + // Covers pointerleave and pointercancel: a touch or pen stream that is taken + // over by the browser never emits a leave, and would otherwise strand the + // readout on screen. + const hideReadout = () => { + cancelReadoutFrame(); + setReadout(null); }; const handleScaleChange = (newScale) => { @@ -149,7 +202,7 @@ const ShapeBuilder = () => { }; const toggleCoordinates = () => { - setShowCoordinates(prev => !prev); + setShowCoordinates((prev) => !prev); }; const handleKeyDown = (e) => { @@ -249,6 +302,7 @@ const ShapeBuilder = () => { useEffect(() => { initializeDrawing(); return () => { + cancelReadoutFrame(); detachKeyListeners(); if (polyRef.current) { polyRef.current.draw("cancel"); @@ -266,9 +320,10 @@ const ShapeBuilder = () => { width="100%" height="100%" onDoubleClick={closeShape} - onMouseMove={handleMouseMove} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} + onPointerMove={handlePointerMove} + onPointerEnter={handlePointerMove} + onPointerLeave={hideReadout} + onPointerCancel={hideReadout} > @@ -278,16 +333,12 @@ const ShapeBuilder = () => { - {isMouseInCanvas && showCoordinates && ( - - X: {mouseCoords.normalized.x}, Y: {mouseCoords.normalized.y} - - )} + {showCoordinates && readout && ( + /* Decorative, pointer-only overlay: keep it out of the a11y tree. */ + + )} {error && (
{ - + diff --git a/site/src/components/ShapeBuilder/shapeBuilder.styles.js b/site/src/components/ShapeBuilder/shapeBuilder.styles.js index f65882d..2a964f8 100644 --- a/site/src/components/ShapeBuilder/shapeBuilder.styles.js +++ b/site/src/components/ShapeBuilder/shapeBuilder.styles.js @@ -1,5 +1,22 @@ import styled from "styled-components"; -// import styled from "@sistent/sistent"; +import { createTheme, darkModePalette, lightModePalette, typography } from "@sistent/sistent"; + +/* + * Sistent design tokens. + * + * styled-components resolves `theme` from the styled-components ThemeProvider in + * src/pages/index.js, which supplies this site's local theme - Sistent's MUI + * theme lives in a separate (emotion) context and is not reachable from here. + * So Sistent's tokens are read straight from the library's exported token sets + * and selected with the same `theme.mode` flag the rest of the site keys off. + */ +const sistentBase = createTheme(); + +const sistentPalette = ({ theme }) => + (theme?.mode === "light" ? lightModePalette : darkModePalette); + +const sistentTypography = ({ theme }) => + typography(theme?.mode === "light" ? "light" : "dark"); // NOTE: background colors are hardcoded-temporarily for testing @@ -140,19 +157,34 @@ export const CopyButton = styled.button` export const CoordinateDisplay = styled.div` position: absolute; + /* Never intercept pointer events - the readout sits over the drawing surface. */ pointer-events: none; - background-color: ${({ theme }) => theme?.mode === "light" ? "rgba(255, 255, 255, 0.95)" : "rgba(43, 43, 43, 0.95)"}; - border: 2px solid #00B39F; - border-radius: 6px; - padding: 6px 10px; - font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; - font-size: 12px; - font-weight: 600; - color: ${({ theme }) => theme?.mode === "light" ? "#111" : "#fff"}; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - z-index: 1000; - user-select: none; + z-index: ${sistentBase.zIndex.tooltip}; + + padding: ${sistentBase.spacing(0.75)} ${sistentBase.spacing(1.25)}; + border: 1px solid ${(props) => sistentPalette(props).border.brand}; + border-radius: ${sistentBase.shape.borderRadius}px; + background-color: ${(props) => sistentPalette(props).background.elevatedComponents}; + color: ${(props) => sistentPalette(props).text.default}; + box-shadow: ${sistentBase.shadows[2]}; + + /* + * Brand font only. "Qanelas Soft" is the family name declared in + * src/fonts.css and used by src/styles/styles.js; Sistent's own token spells + * it "Qanelas Soft Regular", which this site does not load, so the family is + * named here and only the size/weight/rhythm come from the Sistent scale. + */ + font-family: "Qanelas Soft"; + font-size: ${(props) => sistentTypography(props).textL1Bold.fontSize}; + font-weight: ${(props) => sistentTypography(props).textL1Bold.fontWeight}; + line-height: ${(props) => sistentTypography(props).textL1Bold.lineHeight}; + /* Keeps the readout from twitching as digits change width. */ + font-variant-numeric: tabular-nums; + white-space: nowrap; + /* WebKit still needs the prefix for user-select. */ + -webkit-user-select: none; + user-select: none; `; // export const Wrapper = styled.div`