diff --git a/site/src/components/ShapeBuilder/index.js b/site/src/components/ShapeBuilder/index.js
index ecbc32a..6f05baf 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";
@@ -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,6 +60,13 @@ const ShapeBuilder = () => {
const [scale, setScale] = useState(1);
const [currentPreset, setCurrentPreset] = useState(1);
+ // `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;
@@ -90,6 +134,52 @@ const ShapeBuilder = () => {
showCytoArray();
};
+ 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);
+ });
+ };
+
+ // 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)
+ });
+ };
+
+ // 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) => {
const clampedScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale));
setScale(clampedScale);
@@ -111,6 +201,10 @@ const ShapeBuilder = () => {
handleScaleChange(newValue);
};
+ const toggleCoordinates = () => {
+ setShowCoordinates((prev) => !prev);
+ };
+
const handleKeyDown = (e) => {
const poly = polyRef.current;
if (!poly) return;
@@ -208,6 +302,7 @@ const ShapeBuilder = () => {
useEffect(() => {
initializeDrawing();
return () => {
+ cancelReadoutFrame();
detachKeyListeners();
if (polyRef.current) {
polyRef.current.draw("cancel");
@@ -225,6 +320,10 @@ const ShapeBuilder = () => {
width="100%"
height="100%"
onDoubleClick={closeShape}
+ onPointerMove={handlePointerMove}
+ onPointerEnter={handlePointerMove}
+ onPointerLeave={hideReadout}
+ onPointerCancel={hideReadout}
>