diff --git a/lib/drawGraphicsToCanvas.ts b/lib/drawGraphicsToCanvas.ts index 82896a4..e18dd07 100644 --- a/lib/drawGraphicsToCanvas.ts +++ b/lib/drawGraphicsToCanvas.ts @@ -64,6 +64,7 @@ export function getBounds(graphics: GraphicsObject): Viewbox { const points = [ ...(graphics.points || []), ...(graphics.lines || []).flatMap((line) => line.points), + ...(graphics.arrows || []).flatMap((arrow) => [arrow.start, arrow.end]), ...(graphics.rects || []).flatMap((rect) => { const halfWidth = rect.width / 2 const halfHeight = rect.height / 2 @@ -294,6 +295,68 @@ export function drawGraphicsToCanvas( }) } + const drawArrowHead = ( + from: { x: number; y: number }, + to: { x: number; y: number }, + color: string, + headLength: number, + headWidth: number, + ) => { + const angle = Math.atan2(to.y - from.y, to.x - from.x) + const sin = Math.sin(angle) + const cos = Math.cos(angle) + + const point1 = { + x: to.x - headLength * cos + headWidth * sin, + y: to.y - headLength * sin - headWidth * cos, + } + const point2 = { + x: to.x - headLength * cos - headWidth * sin, + y: to.y - headLength * sin + headWidth * cos, + } + + ctx.beginPath() + ctx.moveTo(to.x, to.y) + ctx.lineTo(point1.x, point1.y) + ctx.lineTo(point2.x, point2.y) + ctx.closePath() + ctx.fillStyle = color + ctx.fill() + } + + if (graphics.arrows && graphics.arrows.length > 0) { + graphics.arrows.forEach((arrow, arrowIndex) => { + const start = applyToPoint(matrix, arrow.start) + const end = applyToPoint(matrix, arrow.end) + + const baseColor = + arrow.strokeColor || defaultColors[arrowIndex % defaultColors.length] + + const strokeWidth = + arrow.strokeWidth !== undefined + ? arrow.strokeWidth * Math.abs(matrix.a) + : 2 + + ctx.beginPath() + ctx.moveTo(start.x, start.y) + ctx.lineTo(end.x, end.y) + ctx.strokeStyle = baseColor + ctx.lineWidth = strokeWidth + ctx.lineCap = "round" + ctx.stroke() + + const scale = Math.abs(matrix.a) + const headLength = (arrow.headLength ?? 10) * scale + const headWidth = arrow.headWidth ?? headLength / 2 + + drawArrowHead(start, end, baseColor, headLength, headWidth) + + if (arrow.doubleSided) { + drawArrowHead(end, start, baseColor, headLength, headWidth) + } + }) + } + // Draw points if (graphics.points && graphics.points.length > 0) { graphics.points.forEach((point, pointIndex) => { diff --git a/lib/getSvgFromGraphicsObject.ts b/lib/getSvgFromGraphicsObject.ts index 3d48b76..a18e5cf 100644 --- a/lib/getSvgFromGraphicsObject.ts +++ b/lib/getSvgFromGraphicsObject.ts @@ -25,6 +25,7 @@ function getBounds(graphics: GraphicsObject): Bounds { const points: Point[] = [ ...(graphics.points || []), ...(graphics.lines || []).flatMap((line) => line.points), + ...(graphics.arrows || []).flatMap((arrow) => [arrow.start, arrow.end]), ...(graphics.rects || []).flatMap((rect) => { const halfWidth = rect.width / 2 const halfHeight = rect.height / 2 @@ -118,7 +119,7 @@ export function getSvgFromGraphicsObject( svgWidth = DEFAULT_SVG_SIZE, svgHeight = DEFAULT_SVG_SIZE, }: { - includeTextLabels?: boolean | Array<"points" | "lines" | "rects"> + includeTextLabels?: boolean | Array<"points" | "lines" | "rects" | "arrows"> backgroundColor?: string svgWidth?: number svgHeight?: number @@ -132,7 +133,9 @@ export function getSvgFromGraphicsObject( svgHeight, ) - const shouldRenderLabel = (type: "points" | "lines" | "rects"): boolean => { + const shouldRenderLabel = ( + type: "points" | "lines" | "rects" | "arrows", + ): boolean => { if (typeof includeTextLabels === "boolean") { return includeTextLabels } @@ -142,6 +145,28 @@ export function getSvgFromGraphicsObject( return false } + const computeArrowHeadPoints = ( + tip: { x: number; y: number }, + tail: { x: number; y: number }, + headLength: number, + headWidth: number, + ) => { + const angle = Math.atan2(tip.y - tail.y, tip.x - tail.x) + const sin = Math.sin(angle) + const cos = Math.cos(angle) + + const point1 = { + x: tip.x - headLength * cos + headWidth * sin, + y: tip.y - headLength * sin - headWidth * cos, + } + const point2 = { + x: tip.x - headLength * cos - headWidth * sin, + y: tip.y - headLength * sin + headWidth * cos, + } + + return [tip, point1, point2] + } + const svgObject = { name: "svg", type: "element", @@ -255,6 +280,95 @@ export function getSvgFromGraphicsObject( ], } }), + // Arrows + ...(graphics.arrows || []).map((arrow) => { + const projectedStart = projectPoint(arrow.start, matrix) + const projectedEnd = projectPoint(arrow.end, matrix) + const color = arrow.strokeColor || "black" + const strokeWidth = arrow.strokeWidth ?? 1 + const scale = Math.abs(matrix.a) + const headLength = (arrow.headLength ?? 10) * scale + const headWidth = arrow.headWidth ?? headLength / 2 + + const endHeadPoints = computeArrowHeadPoints( + projectedEnd, + projectedStart, + headLength, + headWidth, + ) + + const children: any[] = [ + { + name: "line", + type: "element", // satisfies svgson types + attributes: { + "data-type": "arrow", + "data-label": arrow.label || "", + "data-start": `${arrow.start.x},${arrow.start.y}`, + "data-end": `${arrow.end.x},${arrow.end.y}`, + x1: projectedStart.x.toString(), + y1: projectedStart.y.toString(), + x2: projectedEnd.x.toString(), + y2: projectedEnd.y.toString(), + stroke: color, + "stroke-width": strokeWidth.toString(), + "stroke-linecap": "round", + }, + }, + { + name: "polygon", + type: "element", + attributes: { + points: endHeadPoints + .map((point) => `${point.x},${point.y}`) + .join(" "), + fill: color, + }, + }, + ] + + if (arrow.doubleSided) { + const startHeadPoints = computeArrowHeadPoints( + projectedStart, + projectedEnd, + headLength, + headWidth, + ) + + children.push({ + name: "polygon", + type: "element", + attributes: { + points: startHeadPoints + .map((point) => `${point.x},${point.y}`) + .join(" "), + fill: color, + }, + }) + } + + if (shouldRenderLabel("arrows") && arrow.label) { + children.push({ + name: "text", + type: "element", + attributes: { + x: projectedEnd.x.toString(), + y: (projectedEnd.y - headLength).toString(), + "font-family": "sans-serif", + "font-size": "12", + fill: color, + }, + children: [{ type: "text", value: arrow.label }], + }) + } + + return { + name: "g", + type: "element", + attributes: {}, + children, + } + }), // Rectangles ...(graphics.rects || []).map((rect) => { const corner1 = { diff --git a/lib/index.ts b/lib/index.ts index a9e08de..ab102f6 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -16,6 +16,7 @@ import { export type { Point, Line, + Arrow, Rect, Circle, Text, diff --git a/lib/mergeGraphics.ts b/lib/mergeGraphics.ts index bbf3dcd..3636cdc 100644 --- a/lib/mergeGraphics.ts +++ b/lib/mergeGraphics.ts @@ -9,6 +9,7 @@ export const mergeGraphics = ( rects: [...(graphics1.rects ?? []), ...(graphics2.rects ?? [])], points: [...(graphics1.points ?? []), ...(graphics2.points ?? [])], lines: [...(graphics1.lines ?? []), ...(graphics2.lines ?? [])], + arrows: [...(graphics1.arrows ?? []), ...(graphics2.arrows ?? [])], circles: [...(graphics1.circles ?? []), ...(graphics2.circles ?? [])], texts: [...(graphics1.texts ?? []), ...(graphics2.texts ?? [])], } diff --git a/lib/translateGraphics.ts b/lib/translateGraphics.ts index 19096b3..52a9541 100644 --- a/lib/translateGraphics.ts +++ b/lib/translateGraphics.ts @@ -16,6 +16,11 @@ export function translateGraphics( ...line, points: line.points.map((pt) => ({ x: pt.x + dx, y: pt.y + dy })), })), + arrows: graphics.arrows?.map((arrow) => ({ + ...arrow, + start: { x: arrow.start.x + dx, y: arrow.start.y + dy }, + end: { x: arrow.end.x + dx, y: arrow.end.y + dy }, + })), rects: graphics.rects?.map((rect) => ({ ...rect, center: { x: rect.center.x + dx, y: rect.center.y + dy }, diff --git a/lib/types.ts b/lib/types.ts index 415a48c..042437d 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -17,6 +17,19 @@ export interface Line { label?: string } +export interface Arrow { + start: { x: number; y: number } + end: { x: number; y: number } + strokeWidth?: number + strokeColor?: string + headLength?: number + headWidth?: number + doubleSided?: boolean + layer?: string + step?: number + label?: string +} + export interface Rect { center: { x: number; y: number } width: number @@ -64,6 +77,7 @@ export interface Text { export interface GraphicsObject { points?: Point[] lines?: Line[] + arrows?: Arrow[] rects?: Rect[] circles?: Circle[] texts?: Text[] diff --git a/site/components/InteractiveGraphics/Arrow.tsx b/site/components/InteractiveGraphics/Arrow.tsx new file mode 100644 index 0000000..de093ca --- /dev/null +++ b/site/components/InteractiveGraphics/Arrow.tsx @@ -0,0 +1,147 @@ +import type * as Types from "lib/types" +import { applyToPoint } from "transformation-matrix" +import type { InteractiveState } from "./InteractiveState" +import { useMemo, useState } from "react" +import type { MouseEvent as ReactMouseEvent } from "react" +import { Tooltip } from "./Tooltip" +import { distToLineSegment } from "site/utils/distToLineSegment" +import { defaultColors } from "./defaultColors" +import { safeLighten } from "site/utils/safeLighten" + +const getArrowHeadPoints = ( + tip: { x: number; y: number }, + tail: { x: number; y: number }, + headLength: number, + headWidth: number, +) => { + const angle = Math.atan2(tip.y - tail.y, tip.x - tail.x) + const sin = Math.sin(angle) + const cos = Math.cos(angle) + + return [ + tip, + { + x: tip.x - headLength * cos + headWidth * sin, + y: tip.y - headLength * sin - headWidth * cos, + }, + { + x: tip.x - headLength * cos - headWidth * sin, + y: tip.y - headLength * sin + headWidth * cos, + }, + ] +} + +export const Arrow = ({ + arrow, + index, + interactiveState, +}: { + arrow: Types.Arrow + index: number + interactiveState: InteractiveState +}) => { + const { realToScreen, onObjectClicked } = interactiveState + const [isHovered, setIsHovered] = useState(false) + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) + + const screenStart = useMemo( + () => applyToPoint(realToScreen, arrow.start), + [arrow.start, realToScreen], + ) + const screenEnd = useMemo( + () => applyToPoint(realToScreen, arrow.end), + [arrow.end, realToScreen], + ) + + const rawScale = Math.abs(realToScreen.a) + const scale = rawScale === 0 ? 1 : rawScale + const strokeWidth = (arrow.strokeWidth ?? 1 / scale) * scale + const color = arrow.strokeColor ?? defaultColors[index % defaultColors.length] + const headLength = (arrow.headLength ?? 10) * scale + const headWidth = arrow.headWidth ?? headLength / 2 + + const endHead = getArrowHeadPoints( + screenEnd, + screenStart, + headLength, + headWidth, + ) + const startHead = arrow.doubleSided + ? getArrowHeadPoints(screenStart, screenEnd, headLength, headWidth) + : null + + const handleMouseMove = (event: ReactMouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect() + const mouseX = event.clientX - rect.left + const mouseY = event.clientY - rect.top + setMousePos({ x: mouseX, y: mouseY }) + + const distance = distToLineSegment( + mouseX, + mouseY, + screenStart.x, + screenStart.y, + screenEnd.x, + screenEnd.y, + ) + + setIsHovered(distance < 10) + } + + const baseColor = color + const displayColor = isHovered ? safeLighten(0.2, baseColor) : baseColor + + return ( + setIsHovered(false)} + onClick={ + isHovered + ? () => + onObjectClicked?.({ + type: "arrow", + index, + object: arrow, + }) + : undefined + } + > + + `${point.x},${point.y}`).join(" ")} + fill={displayColor} + /> + {startHead && ( + `${point.x},${point.y}`).join(" ")} + fill={displayColor} + /> + )} + {isHovered && arrow.label && ( + + + + )} + + ) +} diff --git a/site/components/InteractiveGraphics/InteractiveGraphics.tsx b/site/components/InteractiveGraphics/InteractiveGraphics.tsx index 6d62421..bacbfff 100644 --- a/site/components/InteractiveGraphics/InteractiveGraphics.tsx +++ b/site/components/InteractiveGraphics/InteractiveGraphics.tsx @@ -12,6 +12,7 @@ import { InteractiveState } from "./InteractiveState" import { SuperGrid } from "react-supergrid" import useResizeObserver from "@react-hook/resize-observer" import { Line } from "./Line" +import { Arrow } from "./Arrow" import { Point } from "./Point" import { Rect } from "./Rect" import { Circle } from "./Circle" @@ -22,6 +23,7 @@ import { useIsPointOnScreen, useDoesLineIntersectViewport, useFilterLines, + useFilterArrows, useFilterPoints, useFilterRects, useFilterCircles, @@ -33,7 +35,7 @@ import { ContextMenu } from "./ContextMenu" import { Marker, MarkerPoint } from "./Marker" export type GraphicsObjectClickEvent = { - type: "point" | "line" | "rect" | "circle" | "text" + type: "point" | "line" | "rect" | "circle" | "text" | "arrow" index: number object: any } @@ -61,6 +63,7 @@ export const InteractiveGraphics = ({ const availableLayers: string[] = Array.from( new Set([ ...(graphics.lines?.map((l) => l.layer!).filter(Boolean) ?? []), + ...(graphics.arrows?.map((a) => a.layer!).filter(Boolean) ?? []), ...(graphics.rects?.map((r) => r.layer!).filter(Boolean) ?? []), ...(graphics.points?.map((p) => p.layer!).filter(Boolean) ?? []), ...(graphics.texts?.map((t) => t.layer!).filter(Boolean) ?? []), @@ -316,6 +319,12 @@ export const InteractiveGraphics = ({ filterLayerAndStep, ) + const filterArrows = useFilterArrows( + isPointOnScreen, + doesLineIntersectViewport, + filterLayerAndStep, + ) + const filterPoints = useFilterPoints(isPointOnScreen, filterLayerAndStep) const filterRects = useFilterRects( @@ -347,6 +356,10 @@ export const InteractiveGraphics = ({ () => filterAndLimit(graphics.lines, filterLines), [graphics.lines, filterLines, objectLimit], ) + const filteredArrows = useMemo( + () => filterAndLimit(graphics.arrows, filterArrows), + [graphics.arrows, filterArrows, objectLimit], + ) const filteredRects = useMemo( () => sortRectsByArea(filterAndLimit(graphics.rects, filterRects)), [graphics.rects, filterRects, objectLimit], @@ -366,6 +379,7 @@ export const InteractiveGraphics = ({ const totalFilteredObjects = filteredLines.length + + filteredArrows.length + filteredRects.length + filteredPoints.length + filteredCircles.length + @@ -464,6 +478,14 @@ export const InteractiveGraphics = ({ interactiveState={interactiveState} /> ))} + {filteredArrows.map((arrow) => ( + + ))} {filteredRects.map((rect) => ( boolean, + doesLineIntersectViewport: ( + p1: { x: number; y: number }, + p2: { x: number; y: number }, + ) => boolean, + filterLayerAndStep: (obj: { layer?: string; step?: number }) => boolean, +) => { + return useMemo(() => { + return (arrow: Arrow) => { + if (!filterLayerAndStep(arrow)) return false + + if (isPointOnScreen(arrow.start) || isPointOnScreen(arrow.end)) { + return true + } + + return doesLineIntersectViewport(arrow.start, arrow.end) + } + }, [doesLineIntersectViewport, filterLayerAndStep, isPointOnScreen]) +} diff --git a/site/utils/getGraphicsBounds.ts b/site/utils/getGraphicsBounds.ts index a07813a..fbce08f 100644 --- a/site/utils/getGraphicsBounds.ts +++ b/site/utils/getGraphicsBounds.ts @@ -15,6 +15,15 @@ export const getGraphicsBounds = (graphics: GraphicsObject) => { bounds.maxY = Math.max(bounds.maxY, point.y) } } + for (const arrow of graphics.arrows ?? []) { + const points = [arrow.start, arrow.end] + for (const point of points) { + bounds.minX = Math.min(bounds.minX, point.x) + bounds.minY = Math.min(bounds.minY, point.y) + bounds.maxX = Math.max(bounds.maxX, point.x) + bounds.maxY = Math.max(bounds.maxY, point.y) + } + } for (const rect of graphics.rects ?? []) { const { center, width, height } = rect const halfWidth = width / 2 diff --git a/site/utils/getGraphicsFilteredByStep.ts b/site/utils/getGraphicsFilteredByStep.ts index ec12d0c..a2cd7ed 100644 --- a/site/utils/getGraphicsFilteredByStep.ts +++ b/site/utils/getGraphicsFilteredByStep.ts @@ -26,6 +26,9 @@ export function getGraphicsFilteredByStep( lines: graphics.lines?.filter( (l) => l.step === undefined || l.step === selectedStep, ), + arrows: graphics.arrows?.filter( + (a) => a.step === undefined || a.step === selectedStep, + ), rects: graphics.rects?.filter( (r) => r.step === undefined || r.step === selectedStep, ), diff --git a/site/utils/getMaxStep.ts b/site/utils/getMaxStep.ts index 08859d1..6d72799 100644 --- a/site/utils/getMaxStep.ts +++ b/site/utils/getMaxStep.ts @@ -14,6 +14,7 @@ export function getMaxStep(graphics: GraphicsObject) { const maxPointStep = getMaxStepFromArray(graphics.points) const maxLineStep = getMaxStepFromArray(graphics.lines) + const maxArrowStep = getMaxStepFromArray(graphics.arrows) const maxRectStep = getMaxStepFromArray(graphics.rects) const maxCircleStep = getMaxStepFromArray(graphics.circles) const maxTextStep = getMaxStepFromArray(graphics.texts) @@ -21,6 +22,7 @@ export function getMaxStep(graphics: GraphicsObject) { return Math.max( maxPointStep, maxLineStep, + maxArrowStep, maxRectStep, maxCircleStep, maxTextStep, diff --git a/tests/__snapshots__/arrows.snap.svg b/tests/__snapshots__/arrows.snap.svg new file mode 100644 index 0000000..466bd88 --- /dev/null +++ b/tests/__snapshots__/arrows.snap.svg @@ -0,0 +1,44 @@ +Arrow Label \ No newline at end of file diff --git a/tests/__snapshots__/cartesian-rect.snap.svg b/tests/__snapshots__/cartesian-rect.snap.svg index d0a8deb..a0bc781 100644 --- a/tests/__snapshots__/cartesian-rect.snap.svg +++ b/tests/__snapshots__/cartesian-rect.snap.svg @@ -1,65 +1,44 @@ - - - - - - - - - - \ No newline at end of file + // Calculate real coordinates using inverse transformation + const matrix = {"a":13.176470588235293,"c":0,"e":320,"b":0,"d":-13.176470588235293,"f":468.2352941176471}; + // Manually invert and apply the affine transform + // Since we only use translate and scale, we can directly compute: + // x' = (x - tx) / sx + // y' = (y - ty) / sy + const sx = matrix.a; + const sy = matrix.d; + const tx = matrix.e; + const ty = matrix.f; + const realPoint = { + x: (x - tx) / sx, + y: (y - ty) / sy // Flip y back since we used negative scale + } + + coords.textContent = `(${realPoint.x.toFixed(2)}, ${realPoint.y.toFixed(2)})`; + coords.setAttribute('x', (x + 5).toString()); + coords.setAttribute('y', (y - 5).toString()); + }); + document.currentScript.parentElement.addEventListener('mouseleave', () => { + document.currentScript.parentElement.getElementById('crosshair').style.display = 'none'; + }); + ]]> \ No newline at end of file diff --git a/tests/__snapshots__/circles.snap.svg b/tests/__snapshots__/circles.snap.svg index 8e56da4..ad58d10 100644 --- a/tests/__snapshots__/circles.snap.svg +++ b/tests/__snapshots__/circles.snap.svg @@ -1,60 +1,44 @@ - - - - - \ No newline at end of file + // Calculate real coordinates using inverse transformation + const matrix = {"a":56,"c":0,"e":320,"b":0,"d":-56,"f":320}; + // Manually invert and apply the affine transform + // Since we only use translate and scale, we can directly compute: + // x' = (x - tx) / sx + // y' = (y - ty) / sy + const sx = matrix.a; + const sy = matrix.d; + const tx = matrix.e; + const ty = matrix.f; + const realPoint = { + x: (x - tx) / sx, + y: (y - ty) / sy // Flip y back since we used negative scale + } + + coords.textContent = `(${realPoint.x.toFixed(2)}, ${realPoint.y.toFixed(2)})`; + coords.setAttribute('x', (x + 5).toString()); + coords.setAttribute('y', (y - 5).toString()); + }); + document.currentScript.parentElement.addEventListener('mouseleave', () => { + document.currentScript.parentElement.getElementById('crosshair').style.display = 'none'; + }); + ]]> \ No newline at end of file diff --git a/tests/__snapshots__/lines.snap.svg b/tests/__snapshots__/lines.snap.svg index bfa61ce..50aa306 100644 --- a/tests/__snapshots__/lines.snap.svg +++ b/tests/__snapshots__/lines.snap.svg @@ -1,65 +1,44 @@ - - - - - - - - - - \ No newline at end of file + // Calculate real coordinates using inverse transformation + const matrix = {"a":560,"c":0,"e":40,"b":0,"d":-560,"f":600}; + // Manually invert and apply the affine transform + // Since we only use translate and scale, we can directly compute: + // x' = (x - tx) / sx + // y' = (y - ty) / sy + const sx = matrix.a; + const sy = matrix.d; + const tx = matrix.e; + const ty = matrix.f; + const realPoint = { + x: (x - tx) / sx, + y: (y - ty) / sy // Flip y back since we used negative scale + } + + coords.textContent = `(${realPoint.x.toFixed(2)}, ${realPoint.y.toFixed(2)})`; + coords.setAttribute('x', (x + 5).toString()); + coords.setAttribute('y', (y - 5).toString()); + }); + document.currentScript.parentElement.addEventListener('mouseleave', () => { + document.currentScript.parentElement.getElementById('crosshair').style.display = 'none'; + }); + ]]> \ No newline at end of file diff --git a/tests/__snapshots__/points.snap.svg b/tests/__snapshots__/points.snap.svg index e21d3ff..4105cb6 100644 --- a/tests/__snapshots__/points.snap.svg +++ b/tests/__snapshots__/points.snap.svg @@ -1,65 +1,44 @@ - - - - - - - - - - \ No newline at end of file + // Calculate real coordinates using inverse transformation + const matrix = {"a":560,"c":0,"e":40,"b":0,"d":-560,"f":600}; + // Manually invert and apply the affine transform + // Since we only use translate and scale, we can directly compute: + // x' = (x - tx) / sx + // y' = (y - ty) / sy + const sx = matrix.a; + const sy = matrix.d; + const tx = matrix.e; + const ty = matrix.f; + const realPoint = { + x: (x - tx) / sx, + y: (y - ty) / sy // Flip y back since we used negative scale + } + + coords.textContent = `(${realPoint.x.toFixed(2)}, ${realPoint.y.toFixed(2)})`; + coords.setAttribute('x', (x + 5).toString()); + coords.setAttribute('y', (y - 5).toString()); + }); + document.currentScript.parentElement.addEventListener('mouseleave', () => { + document.currentScript.parentElement.getElementById('crosshair').style.display = 'none'; + }); + ]]> \ No newline at end of file diff --git a/tests/__snapshots__/rectangles.snap.svg b/tests/__snapshots__/rectangles.snap.svg index cf5ac51..5cfa5a6 100644 --- a/tests/__snapshots__/rectangles.snap.svg +++ b/tests/__snapshots__/rectangles.snap.svg @@ -1,62 +1,44 @@ - - - - - - - \ No newline at end of file + // Calculate real coordinates using inverse transformation + const matrix = {"a":28,"c":0,"e":320,"b":0,"d":-28,"f":320}; + // Manually invert and apply the affine transform + // Since we only use translate and scale, we can directly compute: + // x' = (x - tx) / sx + // y' = (y - ty) / sy + const sx = matrix.a; + const sy = matrix.d; + const tx = matrix.e; + const ty = matrix.f; + const realPoint = { + x: (x - tx) / sx, + y: (y - ty) / sy // Flip y back since we used negative scale + } + + coords.textContent = `(${realPoint.x.toFixed(2)}, ${realPoint.y.toFixed(2)})`; + coords.setAttribute('x', (x + 5).toString()); + coords.setAttribute('y', (y - 5).toString()); + }); + document.currentScript.parentElement.addEventListener('mouseleave', () => { + document.currentScript.parentElement.getElementById('crosshair').style.display = 'none'; + }); + ]]> \ No newline at end of file diff --git a/tests/__snapshots__/texts.snap.svg b/tests/__snapshots__/texts.snap.svg index f0a58bc..dc11dc8 100644 --- a/tests/__snapshots__/texts.snap.svg +++ b/tests/__snapshots__/texts.snap.svg @@ -1,59 +1,44 @@ -Hello - - - \ No newline at end of file + // Calculate real coordinates using inverse transformation + const matrix = {"a":11.666666666666666,"c":0,"e":320,"b":0,"d":-11.666666666666666,"f":320}; + // Manually invert and apply the affine transform + // Since we only use translate and scale, we can directly compute: + // x' = (x - tx) / sx + // y' = (y - ty) / sy + const sx = matrix.a; + const sy = matrix.d; + const tx = matrix.e; + const ty = matrix.f; + const realPoint = { + x: (x - tx) / sx, + y: (y - ty) / sy // Flip y back since we used negative scale + } + + coords.textContent = `(${realPoint.x.toFixed(2)}, ${realPoint.y.toFixed(2)})`; + coords.setAttribute('x', (x + 5).toString()); + coords.setAttribute('y', (y - 5).toString()); + }); + document.currentScript.parentElement.addEventListener('mouseleave', () => { + document.currentScript.parentElement.getElementById('crosshair').style.display = 'none'; + }); + ]]> \ No newline at end of file diff --git a/tests/getBounds.test.ts b/tests/getBounds.test.ts index cc02fad..8ac9796 100644 --- a/tests/getBounds.test.ts +++ b/tests/getBounds.test.ts @@ -34,4 +34,21 @@ describe("getBounds with text", () => { expect(bounds.maxY).toBeCloseTo(0) expect(bounds.minY).toBeCloseTo(-height) }) + + test("includes arrows in bounds", () => { + const graphics: GraphicsObject = { + arrows: [ + { + start: { x: -5, y: -5 }, + end: { x: 10, y: 15 }, + }, + ], + } + + const bounds = getBounds(graphics) + expect(bounds.minX).toBeLessThanOrEqual(-5) + expect(bounds.minY).toBeLessThanOrEqual(-5) + expect(bounds.maxX).toBeGreaterThanOrEqual(10) + expect(bounds.maxY).toBeGreaterThanOrEqual(15) + }) }) diff --git a/tests/getSvgFromGraphicsObject.test.ts b/tests/getSvgFromGraphicsObject.test.ts index 960b2d2..18e0ccc 100644 --- a/tests/getSvgFromGraphicsObject.test.ts +++ b/tests/getSvgFromGraphicsObject.test.ts @@ -69,6 +69,29 @@ describe("getSvgFromGraphicsObject", () => { expect(svg).toMatchSvgSnapshot(import.meta.path, "lines") }) + test("should generate SVG with arrows", () => { + const input: GraphicsObject = { + arrows: [ + { + start: { x: 0, y: 0 }, + end: { x: 5, y: 5 }, + strokeColor: "red", + doubleSided: true, + label: "Arrow Label", + }, + ], + } + + const svg = getSvgFromGraphicsObject(input, { + includeTextLabels: ["arrows"], + }) + + expect(svg).toContain('data-type="arrow"') + expect(svg).toContain(" { const input: GraphicsObject = { rects: [ diff --git a/tests/mergeGraphics.test.ts b/tests/mergeGraphics.test.ts index fa61485..2ce3d92 100644 --- a/tests/mergeGraphics.test.ts +++ b/tests/mergeGraphics.test.ts @@ -18,6 +18,12 @@ describe("mergeGraphics", () => { ], }, ], + arrows: [ + { + start: { x: -1, y: -1 }, + end: { x: 4, y: 4 }, + }, + ], circles: [{ center: { x: 4, y: 4 }, radius: 1 }], texts: [{ x: 6, y: 6, text: "b" }], } @@ -33,6 +39,12 @@ describe("mergeGraphics", () => { ], }, ], + arrows: [ + { + start: { x: -1, y: -1 }, + end: { x: 4, y: 4 }, + }, + ], circles: [{ center: { x: 4, y: 4 }, radius: 1 }], texts: [ { x: 5, y: 5, text: "a" }, diff --git a/tests/translateGraphics.test.ts b/tests/translateGraphics.test.ts index e798ad0..4213574 100644 --- a/tests/translateGraphics.test.ts +++ b/tests/translateGraphics.test.ts @@ -14,6 +14,12 @@ describe("translateGraphics", () => { ], }, ], + arrows: [ + { + start: { x: -1, y: -1 }, + end: { x: 2, y: 2 }, + }, + ], rects: [{ center: { x: 2, y: 2 }, width: 2, height: 2 }], circles: [{ center: { x: 3, y: 3 }, radius: 1 }], texts: [{ x: 4, y: 4, text: "hi" }], @@ -29,6 +35,12 @@ describe("translateGraphics", () => { ], }, ], + arrows: [ + { + start: { x: 4, y: -4 }, + end: { x: 7, y: -1 }, + }, + ], rects: [{ center: { x: 7, y: -1 }, width: 2, height: 2 }], circles: [{ center: { x: 8, y: 0 }, radius: 1 }], texts: [{ x: 9, y: 1, text: "hi" }],