From 1e2d3972e5015fb304201a3e80210b9957567a55 Mon Sep 17 00:00:00 2001 From: brandtnewlabs <44766428+brandtnewlabs@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:50:48 +0200 Subject: [PATCH] feat: add drawing tools proof of concept --- app/(tabs)/index.tsx | 5 + app/demo/drawing-tools-poc.tsx | 155 +++++++++++ demo-lib/DrawingToolsPocOverlay.tsx | 417 ++++++++++++++++++++++++++++ demo-lib/drawingToolsPoc.test.ts | 51 ++++ demo-lib/drawingToolsPoc.ts | 108 +++++++ 5 files changed, 736 insertions(+) create mode 100644 app/demo/drawing-tools-poc.tsx create mode 100644 demo-lib/DrawingToolsPocOverlay.tsx create mode 100644 demo-lib/drawingToolsPoc.test.ts create mode 100644 demo-lib/drawingToolsPoc.ts diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index aac4fa0..f5f7099 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -133,6 +133,11 @@ const SECTIONS: DemoSection[] = [ title: "Overlay bridge", blurb: "renderOverlay: hand-rolled RN order overlay via the priceToY / yToPrice / timeToX bridge.", }, + { + href: "/demo/drawing-tools-poc" as Href, + title: "Drawing tools POC", + blurb: "Internal spike: draw, select, and drag time/price-anchored trend-line segments.", + }, ], }, { diff --git a/app/demo/drawing-tools-poc.tsx b/app/demo/drawing-tools-poc.tsx new file mode 100644 index 0000000..7d24261 --- /dev/null +++ b/app/demo/drawing-tools-poc.tsx @@ -0,0 +1,155 @@ +import { useState } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { LiveChart } from "react-native-livechart"; +import { useSharedValue } from "react-native-reanimated"; + +import { Chip, ChipRow, ControlRow } from "../../demo-lib/ChipRow"; +import { + DrawingToolsPocOverlay, + type DrawingPocMode, +} from "../../demo-lib/DrawingToolsPocOverlay"; +import { DemoScreen } from "../../demo-lib/DemoScreen"; +import type { TrendLineDrawing } from "../../demo-lib/drawingToolsPoc"; +import { ACCENT } from "../../demo-lib/shared"; +import { demoStyles } from "../../demo-lib/styles"; +import { APP_THEME, colors } from "../../demo-lib/theme"; +import { useSimulatedChartData } from "../../sim/useSimulatedChartData"; + +export const options = { title: "Drawing tools POC" }; + +const WINDOW_SECS = 5 * 60; +const MODE_OPTIONS: readonly { value: DrawingPocMode; label: string }[] = [ + { value: "browse", label: "Browse" }, + { value: "draw", label: "Draw line" }, + { value: "edit", label: "Edit" }, +]; + +export default function DrawingToolsPocScreen() { + const [mode, setMode] = useState("edit"); + const [status, setStatus] = useState( + "Seed line selected — drag a handle or its body", + ); + const [lineCount, setLineCount] = useState(1); + const [initialLines] = useState(() => { + const now = Date.now() / 1000; + return [ + { + id: "line-1", + start: { time: now - 240, value: 99.45 }, + end: { time: now - 60, value: 100.55 }, + }, + ]; + }); + const drawings = useSharedValue(initialLines); + const selectedIndex = useSharedValue(0); + const { data, value } = useSimulatedChartData({ + multiSeries: false, + tradeStream: false, + historySpanSeconds: WINDOW_SECS * 2, + historyRange: "1m", + volatilityMode: "volatile", + }); + + const handleCreated = (id: string) => { + setLineCount(drawings.get().length); + setStatus(`Created ${id} — edit mode is now active`); + setMode("edit"); + }; + + const clearLines = () => { + drawings.set([]); + selectedIndex.set(-1); + setLineCount(0); + setStatus("Cleared — choose Draw line and drag across the plot"); + setMode("draw"); + }; + + return ( + { + if (mode === "browse") setStatus("Chart scrub started"); + }} + onGestureEnd={() => { + if (mode === "browse") setStatus("Chart scrub ended"); + }} + renderOverlay={(context) => ( + + )} + /> + } + > + { + setMode(nextMode); + setStatus( + nextMode === "browse" + ? "Browse mode — drag the chart crosshair" + : nextMode === "draw" + ? "Draw mode — drag across the plot to add a line" + : "Edit mode — drag a handle or line body", + ); + }} + /> + + + + + + {lineCount} {lineCount === 1 ? "line" : "lines"} + + {status} + + + POC boundary: segment only; no snapping, rays, undo stack, persistence, + or public library API. + + + ); +} + +const styles = StyleSheet.create({ + statusPanel: { + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + backgroundColor: colors.chipBackground, + paddingHorizontal: 12, + paddingVertical: 10, + marginTop: 4, + }, + statusTitle: { + color: colors.text, + fontSize: 13, + fontWeight: "600", + marginBottom: 3, + }, + statusBody: { + color: colors.textMuted, + fontSize: 12, + }, + note: { + opacity: 0.62, + marginTop: 10, + lineHeight: 18, + }, +}); diff --git a/demo-lib/DrawingToolsPocOverlay.tsx b/demo-lib/DrawingToolsPocOverlay.tsx new file mode 100644 index 0000000..4072804 --- /dev/null +++ b/demo-lib/DrawingToolsPocOverlay.tsx @@ -0,0 +1,417 @@ +import { useMemo } from "react"; +import { StyleSheet } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { + Canvas, + Circle, + Group, + Path, + Skia, +} from "@shopify/react-native-skia"; +import Animated, { + useDerivedValue, + useSharedValue, + type SharedValue, +} from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; +import type { + ChartOverlayContext, + ChartScale, +} from "react-native-livechart"; + +import { ACCENT } from "./shared"; +import { + clampDrawingCoordinate, + hitTestTrendLine, + translateTrendLine, + type DrawingAnchor, + type ScreenTrendLine, + type TrendLineDragTarget, + type TrendLineDrawing, +} from "./drawingToolsPoc"; + +export type DrawingPocMode = "browse" | "draw" | "edit"; + +const HANDLE_RADIUS = 7; +const HANDLE_HIT_RADIUS = 18; +const LINE_HIT_RADIUS = 12; +const MIN_LINE_LENGTH = 18; +const EMPTY_ANCHOR: DrawingAnchor = { time: 0, value: 0 }; +const EMPTY_LINE: TrendLineDrawing = { + id: "", + start: EMPTY_ANCHOR, + end: EMPTY_ANCHOR, +}; + +type DragTarget = TrendLineDragTarget | "create"; + +function projectLine( + drawing: TrendLineDrawing, + scale: ChartScale, + timeToX: ChartOverlayContext["timeToX"], + priceToY: ChartOverlayContext["priceToY"], +): ScreenTrendLine { + "worklet"; + return { + startX: timeToX(drawing.start.time, scale), + startY: priceToY(drawing.start.value, scale), + endX: timeToX(drawing.end.time, scale), + endY: priceToY(drawing.end.value, scale), + }; +} + +function anchorAtTouch( + x: number, + y: number, + scale: ChartScale, + xToTime: ChartOverlayContext["xToTime"], + yToPrice: ChartOverlayContext["yToPrice"], +): DrawingAnchor | null { + "worklet"; + const clampedX = clampDrawingCoordinate(x, scale.plot.left, scale.plot.right); + const clampedY = clampDrawingCoordinate(y, scale.plot.top, scale.plot.bottom); + const value = yToPrice(clampedY, scale); + if (value === null) return null; + return { time: xToTime(clampedX, scale), value }; +} + +function findDrawingHit( + drawings: TrendLineDrawing[], + selectedIndex: number, + x: number, + y: number, + scale: ChartScale, + timeToX: ChartOverlayContext["timeToX"], + priceToY: ChartOverlayContext["priceToY"], +): { index: number; target: TrendLineDragTarget } { + "worklet"; + if (selectedIndex >= 0 && selectedIndex < drawings.length) { + const selectedHit = hitTestTrendLine( + projectLine(drawings[selectedIndex], scale, timeToX, priceToY), + x, + y, + HANDLE_HIT_RADIUS, + LINE_HIT_RADIUS, + ); + if (selectedHit !== "none") { + return { index: selectedIndex, target: selectedHit }; + } + } + + for (let index = drawings.length - 1; index >= 0; index--) { + if (index === selectedIndex) continue; + const target = hitTestTrendLine( + projectLine(drawings[index], scale, timeToX, priceToY), + x, + y, + HANDLE_HIT_RADIUS, + LINE_HIT_RADIUS, + ); + if (target !== "none") return { index, target }; + } + return { index: -1, target: "none" }; +} + +export function DrawingToolsPocOverlay({ + context, + drawings, + selectedIndex, + mode, + onCreated, + onStatus, +}: { + context: ChartOverlayContext; + drawings: SharedValue; + selectedIndex: SharedValue; + mode: DrawingPocMode; + onCreated: (id: string) => void; + onStatus: (message: string) => void; +}) { + // Destructure every worklet dependency so none of the gesture callbacks close + // over ChartOverlayContext as an opaque object. + const { scale, timeToX, priceToY, xToTime, yToPrice } = context; + const allBuilder = useSharedValue( + useMemo(() => Skia.PathBuilder.Make(), []), + ); + const selectedBuilder = useSharedValue( + useMemo(() => Skia.PathBuilder.Make(), []), + ); + const nextId = useSharedValue(2); + const dragTarget = useSharedValue("none"); + const dragIndex = useSharedValue(-1); + const dragOrigin = useSharedValue(EMPTY_LINE); + const touchOrigin = useSharedValue(EMPTY_ANCHOR); + const touchStartX = useSharedValue(0); + const touchStartY = useSharedValue(0); + const moved = useSharedValue(false); + + const allPath = useDerivedValue(() => { + const builder = allBuilder.get(); + const chartScale = scale.get(); + const items = drawings.get(); + for (let index = 0; index < items.length; index++) { + const line = projectLine(items[index], chartScale, timeToX, priceToY); + builder.moveTo(line.startX, line.startY); + builder.lineTo(line.endX, line.endY); + } + return builder.detach(); + }); + + const selectedPath = useDerivedValue(() => { + const builder = selectedBuilder.get(); + const items = drawings.get(); + const index = selectedIndex.get(); + if (index >= 0 && index < items.length) { + const line = projectLine(items[index], scale.get(), timeToX, priceToY); + builder.moveTo(line.startX, line.startY); + builder.lineTo(line.endX, line.endY); + } + return builder.detach(); + }); + + const plotClip = useDerivedValue(() => { + const plot = scale.get().plot; + return { + x: plot.left, + y: plot.top, + width: Math.max(0, plot.right - plot.left), + height: Math.max(0, plot.bottom - plot.top), + }; + }); + + const handleOpacity = useDerivedValue(() => { + const index = selectedIndex.get(); + return mode === "edit" && index >= 0 && index < drawings.get().length + ? 1 + : 0; + }); + const startHandleX = useDerivedValue(() => { + const items = drawings.get(); + const index = selectedIndex.get(); + return index >= 0 && index < items.length + ? timeToX(items[index].start.time, scale.get()) + : -100; + }); + const startHandleY = useDerivedValue(() => { + const items = drawings.get(); + const index = selectedIndex.get(); + return index >= 0 && index < items.length + ? priceToY(items[index].start.value, scale.get()) + : -100; + }); + const endHandleX = useDerivedValue(() => { + const items = drawings.get(); + const index = selectedIndex.get(); + return index >= 0 && index < items.length + ? timeToX(items[index].end.time, scale.get()) + : -100; + }); + const endHandleY = useDerivedValue(() => { + const items = drawings.get(); + const index = selectedIndex.get(); + return index >= 0 && index < items.length + ? priceToY(items[index].end.value, scale.get()) + : -100; + }); + + function reportCreated(id: string) { + onCreated(id); + } + + function reportStatus(message: string) { + onStatus(message); + } + + const gesture = Gesture.Pan() + .enabled(mode !== "browse") + .maxPointers(1) + .minDistance(0) + .onBegin((event) => { + "worklet"; + const chartScale = scale.get(); + const plot = chartScale.plot; + if ( + event.x < plot.left || + event.x > plot.right || + event.y < plot.top || + event.y > plot.bottom + ) { + dragTarget.set("none"); + dragIndex.set(-1); + return; + } + + touchStartX.set(event.x); + touchStartY.set(event.y); + moved.set(false); + const anchor = anchorAtTouch(event.x, event.y, chartScale, xToTime, yToPrice); + if (anchor === null) return; + touchOrigin.set(anchor); + + if (mode === "draw") { + const id = `line-${nextId.get()}`; + nextId.set(nextId.get() + 1); + const items = drawings.get().slice(); + items.push({ id, start: anchor, end: anchor }); + drawings.set(items); + const index = items.length - 1; + selectedIndex.set(index); + dragIndex.set(index); + dragTarget.set("create"); + return; + } + + const items = drawings.get(); + const hit = findDrawingHit( + items, + selectedIndex.get(), + event.x, + event.y, + chartScale, + timeToX, + priceToY, + ); + selectedIndex.set(hit.index); + dragIndex.set(hit.index); + dragTarget.set(hit.target); + if (hit.index >= 0) { + dragOrigin.set(items[hit.index]); + scheduleOnRN(reportStatus, `Selected ${items[hit.index].id}`); + } else { + scheduleOnRN(reportStatus, "No line selected"); + } + }) + .onUpdate((event) => { + "worklet"; + const target = dragTarget.get(); + const index = dragIndex.get(); + const items = drawings.get(); + if (target === "none" || index < 0 || index >= items.length) return; + + if ( + Math.hypot(event.x - touchStartX.get(), event.y - touchStartY.get()) > 1 + ) { + moved.set(true); + } + const anchor = anchorAtTouch( + event.x, + event.y, + scale.get(), + xToTime, + yToPrice, + ); + if (anchor === null) return; + + const next = items.slice(); + if (target === "create" || target === "end") { + next[index] = { ...next[index], end: anchor }; + } else if (target === "start") { + next[index] = { ...next[index], start: anchor }; + } else { + const origin = touchOrigin.get(); + next[index] = translateTrendLine( + dragOrigin.get(), + anchor.time - origin.time, + anchor.value - origin.value, + ); + } + drawings.set(next); + }) + .onFinalize(() => { + "worklet"; + const target = dragTarget.get(); + const index = dragIndex.get(); + const items = drawings.get(); + if (target === "create" && index >= 0 && index < items.length) { + const line = projectLine(items[index], scale.get(), timeToX, priceToY); + const length = Math.hypot( + line.endX - line.startX, + line.endY - line.startY, + ); + if (length < MIN_LINE_LENGTH) { + const next = items.slice(); + next.splice(index, 1); + drawings.set(next); + selectedIndex.set(-1); + scheduleOnRN(reportStatus, "Drag farther to create a line"); + } else { + scheduleOnRN(reportCreated, items[index].id); + } + } else if ( + target !== "none" && + index >= 0 && + index < items.length && + moved.get() + ) { + scheduleOnRN(reportStatus, `Updated ${items[index].id}`); + } + dragTarget.set("none"); + dragIndex.set(-1); + moved.set(false); + }); + + return ( + + + + + + + + + + + + + + {mode === "browse" ? null : ( + + + + )} + + ); +} diff --git a/demo-lib/drawingToolsPoc.test.ts b/demo-lib/drawingToolsPoc.test.ts new file mode 100644 index 0000000..3b8f9a8 --- /dev/null +++ b/demo-lib/drawingToolsPoc.test.ts @@ -0,0 +1,51 @@ +import { + clampDrawingCoordinate, + distanceToSegment, + hitTestTrendLine, + translateTrendLine, + type ScreenTrendLine, + type TrendLineDrawing, +} from "./drawingToolsPoc"; + +const LINE: ScreenTrendLine = { + startX: 10, + startY: 20, + endX: 110, + endY: 20, +}; + +describe("drawing-tools POC geometry", () => { + test("clamps touch coordinates to the plot", () => { + expect(clampDrawingCoordinate(-5, 10, 90)).toBe(10); + expect(clampDrawingCoordinate(42, 10, 90)).toBe(42); + expect(clampDrawingCoordinate(120, 10, 90)).toBe(90); + }); + + test("measures distance to the finite segment, including past its ends", () => { + expect(distanceToSegment(60, 30, 10, 20, 110, 20)).toBe(10); + expect(distanceToSegment(0, 20, 10, 20, 110, 20)).toBe(10); + expect(distanceToSegment(13, 24, 10, 20, 10, 20)).toBe(5); + }); + + test("gives endpoint handles priority over the line body", () => { + expect(hitTestTrendLine(LINE, 12, 21, 12, 8)).toBe("start"); + expect(hitTestTrendLine(LINE, 108, 21, 12, 8)).toBe("end"); + expect(hitTestTrendLine(LINE, 60, 25, 12, 8)).toBe("body"); + expect(hitTestTrendLine(LINE, 60, 40, 12, 8)).toBe("none"); + }); + + test("translates both anchors without mutating the source", () => { + const line: TrendLineDrawing = { + id: "line-1", + start: { time: 100, value: 20 }, + end: { time: 140, value: 30 }, + }; + + expect(translateTrendLine(line, 5, -2)).toEqual({ + id: "line-1", + start: { time: 105, value: 18 }, + end: { time: 145, value: 28 }, + }); + expect(line.start).toEqual({ time: 100, value: 20 }); + }); +}); diff --git a/demo-lib/drawingToolsPoc.ts b/demo-lib/drawingToolsPoc.ts new file mode 100644 index 0000000..d39bf68 --- /dev/null +++ b/demo-lib/drawingToolsPoc.ts @@ -0,0 +1,108 @@ +/** + * Serializable time/price anchor used by the drawing-tools proof of concept. + * The POC deliberately stores domain coordinates rather than screen pixels so + * its geometry keeps following the chart while the live scale moves. + */ +export interface DrawingAnchor { + time: number; + value: number; +} + +/** One finite, two-anchor trend-line segment. */ +export interface TrendLineDrawing { + id: string; + start: DrawingAnchor; + end: DrawingAnchor; +} + +export type TrendLineDragTarget = "none" | "start" | "end" | "body"; + +/** Screen-space line used only for touch hit-testing. */ +export interface ScreenTrendLine { + startX: number; + startY: number; + endX: number; + endY: number; +} + +/** Clamp a touch coordinate into one plot-axis interval. */ +export function clampDrawingCoordinate( + coordinate: number, + min: number, + max: number, +): number { + "worklet"; + return Math.min(max, Math.max(min, coordinate)); +} + +/** Euclidean distance from a point to a finite screen-space segment. */ +export function distanceToSegment( + x: number, + y: number, + startX: number, + startY: number, + endX: number, + endY: number, +): number { + "worklet"; + const dx = endX - startX; + const dy = endY - startY; + const lengthSquared = dx * dx + dy * dy; + if (lengthSquared <= 0) return Math.hypot(x - startX, y - startY); + + const projected = ((x - startX) * dx + (y - startY) * dy) / lengthSquared; + const t = Math.min(1, Math.max(0, projected)); + const nearestX = startX + dx * t; + const nearestY = startY + dy * t; + return Math.hypot(x - nearestX, y - nearestY); +} + +/** + * Hit-test one line. Endpoint handles intentionally win over its body so the + * selected line remains precise to edit even on a small mobile canvas. + */ +export function hitTestTrendLine( + line: ScreenTrendLine, + x: number, + y: number, + handleRadius: number, + lineRadius: number, +): TrendLineDragTarget { + "worklet"; + if (Math.hypot(x - line.startX, y - line.startY) <= handleRadius) { + return "start"; + } + if (Math.hypot(x - line.endX, y - line.endY) <= handleRadius) { + return "end"; + } + return distanceToSegment( + x, + y, + line.startX, + line.startY, + line.endX, + line.endY, + ) <= lineRadius + ? "body" + : "none"; +} + +/** Translate both anchors by one domain-space delta. */ +export function translateTrendLine( + line: TrendLineDrawing, + timeDelta: number, + valueDelta: number, +): TrendLineDrawing { + "worklet"; + return { + ...line, + start: { + time: line.start.time + timeDelta, + value: line.start.value + valueDelta, + }, + end: { + time: line.end.time + timeDelta, + value: line.end.value + valueDelta, + }, + }; +}