Compare commits
No commits in common. "93b05f554cae802ec65968b5824731fb6c0132b2" and "fe61ba3cf27d8e3a9b5182e5e3cda0f536d0b45b" have entirely different histories.
93b05f554c
...
fe61ba3cf2
@ -1,9 +1,43 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32">
|
||||||
<text
|
<!-- Simplified SkwikLogo for favicon — squirrel head with hard hat and goggles -->
|
||||||
x="50%"
|
|
||||||
y="50%"
|
<!-- Head -->
|
||||||
text-anchor="middle"
|
<ellipse cx="32" cy="36" rx="16" ry="17" fill="#1a1a1a"/>
|
||||||
dominant-baseline="central"
|
|
||||||
font-size="56"
|
<!-- Ear tufts -->
|
||||||
>📐</text>
|
<path d="M16 24 Q10 14 6 17 Q5 22 14 26" fill="#1a1a1a"/>
|
||||||
|
<path d="M48 24 Q54 14 58 17 Q59 22 50 26" fill="#1a1a1a"/>
|
||||||
|
|
||||||
|
<!-- Cheeks -->
|
||||||
|
<ellipse cx="20" cy="40" rx="5" ry="4" fill="#2a2a2a"/>
|
||||||
|
<ellipse cx="44" cy="40" rx="5" ry="4" fill="#2a2a2a"/>
|
||||||
|
|
||||||
|
<!-- Eyes -->
|
||||||
|
<circle cx="25" cy="34" r="4" fill="#fff"/>
|
||||||
|
<circle cx="39" cy="34" r="4" fill="#fff"/>
|
||||||
|
<circle cx="26" cy="33.5" r="2.4" fill="#1a1a1a"/>
|
||||||
|
<circle cx="40" cy="33.5" r="2.4" fill="#1a1a1a"/>
|
||||||
|
<!-- Eye shine -->
|
||||||
|
<circle cx="27" cy="32.5" r="0.9" fill="#fff"/>
|
||||||
|
<circle cx="41" cy="32.5" r="0.9" fill="#fff"/>
|
||||||
|
|
||||||
|
<!-- Nose -->
|
||||||
|
<ellipse cx="32" cy="41" rx="2.2" ry="1.5" fill="#555"/>
|
||||||
|
<!-- Mouth -->
|
||||||
|
<path d="M29.5 44 Q32 46.5 34.5 44" fill="none" stroke="#555" stroke-width="1" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Safety goggles -->
|
||||||
|
<path d="M16 28 Q24 24 32 25.5 Q40 24 48 28" fill="none" stroke="#f59e0b" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<ellipse cx="24" cy="28" rx="5" ry="3.5" fill="none" stroke="#f59e0b" stroke-width="2"/>
|
||||||
|
<ellipse cx="40" cy="28" rx="5" ry="3.5" fill="none" stroke="#f59e0b" stroke-width="2"/>
|
||||||
|
<path d="M29 28 Q32 26.5 35 28" fill="none" stroke="#f59e0b" stroke-width="1.5"/>
|
||||||
|
<ellipse cx="24" cy="28" rx="3.5" ry="2" fill="#f59e0b" opacity="0.15"/>
|
||||||
|
<ellipse cx="40" cy="28" rx="3.5" ry="2" fill="#f59e0b" opacity="0.15"/>
|
||||||
|
|
||||||
|
<!-- Hard hat -->
|
||||||
|
<path d="M12 22 Q12 8 32 6 Q52 8 52 22" fill="none" stroke="#f59e0b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<!-- Hat brim -->
|
||||||
|
<path d="M10 22 L54 22" stroke="#f59e0b" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<!-- Hat dome highlight -->
|
||||||
|
<path d="M24 12 Q32 9 40 12" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 214 B After Width: | Height: | Size: 2.0 KiB |
@ -32,7 +32,7 @@ const viewOffsetX = ref(0)
|
|||||||
const viewOffsetY = ref(0)
|
const viewOffsetY = ref(0)
|
||||||
|
|
||||||
// Tool state
|
// Tool state
|
||||||
type ToolMode = "none" | "line" | "rectangle" | "ellipse" | "angle"
|
type ToolMode = "none" | "line" | "ellipse" | "angle"
|
||||||
const activeTool = ref<ToolMode>("none")
|
const activeTool = ref<ToolMode>("none")
|
||||||
const showGrid = ref(false)
|
const showGrid = ref(false)
|
||||||
const gridSpacingMm = ref(10)
|
const gridSpacingMm = ref(10)
|
||||||
@ -48,14 +48,6 @@ interface LineMeasurement extends BaseMeasurement {
|
|||||||
a: Point
|
a: Point
|
||||||
b: Point
|
b: Point
|
||||||
}
|
}
|
||||||
// Corner ordering [TL, TR, BR, BL] mirrors the RectDatum convention from
|
|
||||||
// src/types/index.ts. Indices stay stable across drags even if the user
|
|
||||||
// crosses corners — visual + hit-tests don't depend on TL actually being
|
|
||||||
// top-left after reshape, matching the datum editor's behaviour.
|
|
||||||
interface RectMeasurement extends BaseMeasurement {
|
|
||||||
type: "rectangle"
|
|
||||||
corners: [Point, Point, Point, Point]
|
|
||||||
}
|
|
||||||
interface EllipseMeasurement extends BaseMeasurement {
|
interface EllipseMeasurement extends BaseMeasurement {
|
||||||
type: "ellipse"
|
type: "ellipse"
|
||||||
center: Point
|
center: Point
|
||||||
@ -68,11 +60,7 @@ interface AngleMeasurement extends BaseMeasurement {
|
|||||||
armA: Point
|
armA: Point
|
||||||
armB: Point
|
armB: Point
|
||||||
}
|
}
|
||||||
type Measurement =
|
type Measurement = LineMeasurement | EllipseMeasurement | AngleMeasurement
|
||||||
| LineMeasurement
|
|
||||||
| RectMeasurement
|
|
||||||
| EllipseMeasurement
|
|
||||||
| AngleMeasurement
|
|
||||||
|
|
||||||
const measurements = ref<Measurement[]>([])
|
const measurements = ref<Measurement[]>([])
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
@ -91,11 +79,7 @@ let isPanning = false
|
|||||||
let panStart = { x: 0, y: 0 }
|
let panStart = { x: 0, y: 0 }
|
||||||
let lastPinchDist = 0
|
let lastPinchDist = 0
|
||||||
|
|
||||||
// Drag state for moving/reshaping committed measurements. We don't gate
|
// Drag state for moving/reshaping committed measurements.
|
||||||
// drag-start on a movement threshold — handles begin tracking the cursor
|
|
||||||
// on the very first move event so positioning feels precise (matches
|
|
||||||
// Konva's behaviour in the datum editor). A pure click without movement
|
|
||||||
// never enters pointerMove, so selection on its own remains drift-free.
|
|
||||||
type DragMode = "none" | "move" | "handle"
|
type DragMode = "none" | "move" | "handle"
|
||||||
interface DragState {
|
interface DragState {
|
||||||
mode: DragMode
|
mode: DragMode
|
||||||
@ -106,8 +90,14 @@ interface DragState {
|
|||||||
startImg: Point
|
startImg: Point
|
||||||
// Snapshot of the measurement at drag start, for delta-based updates.
|
// Snapshot of the measurement at drag start, for delta-based updates.
|
||||||
startSnapshot: Measurement
|
startSnapshot: Measurement
|
||||||
|
// Did this pointer down actually move? Used to distinguish click vs drag.
|
||||||
|
moved: boolean
|
||||||
}
|
}
|
||||||
let dragState: DragState | null = null
|
let dragState: DragState | null = null
|
||||||
|
// Pixel threshold in screen space before a press becomes a drag. Small enough
|
||||||
|
// that intentional drags feel responsive; large enough that a shaky click
|
||||||
|
// still registers as a click.
|
||||||
|
const DRAG_THRESHOLD_PX = 3
|
||||||
|
|
||||||
function loadImg() {
|
function loadImg() {
|
||||||
const image = new Image()
|
const image = new Image()
|
||||||
@ -303,35 +293,6 @@ function angleDegrees(m: AngleMeasurement): number {
|
|||||||
return (rad * 180) / Math.PI
|
return (rad * 180) / Math.PI
|
||||||
}
|
}
|
||||||
|
|
||||||
// Width = mean of TL→TR and BL→BR (the two "horizontal" sides under the
|
|
||||||
// stored ordering). Height = mean of TL→BL and TR→BR. This averages out
|
|
||||||
// minor non-rectangular skew the user may introduce while reshaping.
|
|
||||||
function rectDimensionsMm(m: RectMeasurement): { widthMm: number; heightMm: number } {
|
|
||||||
const [tl, tr, br, bl] = m.corners
|
|
||||||
const wTop = Math.hypot(tr.x - tl.x, tr.y - tl.y)
|
|
||||||
const wBot = Math.hypot(br.x - bl.x, br.y - bl.y)
|
|
||||||
const hLeft = Math.hypot(bl.x - tl.x, bl.y - tl.y)
|
|
||||||
const hRight = Math.hypot(br.x - tr.x, br.y - tr.y)
|
|
||||||
return {
|
|
||||||
widthMm: (wTop + wBot) / 2 / props.scalePxPerMm,
|
|
||||||
heightMm: (hLeft + hRight) / 2 / props.scalePxPerMm,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shoelace area of the quadrilateral, sign-stripped — handles skewed/
|
|
||||||
// reshaped rectangles correctly. Crossed quads will give a smaller area
|
|
||||||
// (common shoelace behaviour); we accept that since we don't auto-reorder.
|
|
||||||
function rectAreaMm2(m: RectMeasurement): number {
|
|
||||||
const [p0, p1, p2, p3] = m.corners
|
|
||||||
const cross =
|
|
||||||
p0.x * p1.y - p1.x * p0.y +
|
|
||||||
p1.x * p2.y - p2.x * p1.y +
|
|
||||||
p2.x * p3.y - p3.x * p2.y +
|
|
||||||
p3.x * p0.y - p0.x * p3.y
|
|
||||||
const areaPx2 = Math.abs(cross) / 2
|
|
||||||
return areaPx2 / (props.scalePxPerMm * props.scalePxPerMm)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMm(v: number): string {
|
function formatMm(v: number): string {
|
||||||
return v >= 10 ? v.toFixed(1) : v.toFixed(2)
|
return v >= 10 ? v.toFixed(1) : v.toFixed(2)
|
||||||
}
|
}
|
||||||
@ -342,21 +303,10 @@ function formatArea(v: number): string {
|
|||||||
return v.toFixed(2)
|
return v.toFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spec for rectangle area readout: 0 decimals when ≥ 100 mm², else 1.
|
|
||||||
function formatRectArea(v: number): string {
|
|
||||||
if (v >= 100) return v.toFixed(0)
|
|
||||||
return v.toFixed(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function measurementLabel(m: Measurement): string {
|
function measurementLabel(m: Measurement): string {
|
||||||
if (m.type === "line") {
|
if (m.type === "line") {
|
||||||
return `${formatMm(lineLengthMm(m))} mm`
|
return `${formatMm(lineLengthMm(m))} mm`
|
||||||
}
|
}
|
||||||
if (m.type === "rectangle") {
|
|
||||||
const { widthMm, heightMm } = rectDimensionsMm(m)
|
|
||||||
const area = rectAreaMm2(m)
|
|
||||||
return `${widthMm.toFixed(1)} × ${heightMm.toFixed(1)} mm · ${formatRectArea(area)} mm²`
|
|
||||||
}
|
|
||||||
if (m.type === "ellipse") {
|
if (m.type === "ellipse") {
|
||||||
const { semiMajor, semiMinor } = ellipseAxesMm(m)
|
const { semiMajor, semiMinor } = ellipseAxesMm(m)
|
||||||
const area = Math.PI * semiMajor * semiMinor
|
const area = Math.PI * semiMajor * semiMinor
|
||||||
@ -367,34 +317,16 @@ function measurementLabel(m: Measurement): string {
|
|||||||
|
|
||||||
function measurementTypeLabel(m: Measurement): string {
|
function measurementTypeLabel(m: Measurement): string {
|
||||||
if (m.type === "line") return "Line"
|
if (m.type === "line") return "Line"
|
||||||
if (m.type === "rectangle") return "Rect"
|
|
||||||
if (m.type === "ellipse") return "Ellipse"
|
if (m.type === "ellipse") return "Ellipse"
|
||||||
return "Angle"
|
return "Angle"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Side-panel summary uses the shorter "w×h mm" without the area suffix per
|
|
||||||
// the spec — a separate format from the on-canvas label.
|
|
||||||
function measurementSummaryValue(m: Measurement): string {
|
|
||||||
if (m.type === "rectangle") {
|
|
||||||
const { widthMm, heightMm } = rectDimensionsMm(m)
|
|
||||||
return `${widthMm.toFixed(1)}×${heightMm.toFixed(1)} mm`
|
|
||||||
}
|
|
||||||
return measurementLabel(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anchor point in image space where we place the label. Chosen per type so
|
// Anchor point in image space where we place the label. Chosen per type so
|
||||||
// the label sits in a predictable, non-occluding position.
|
// the label sits in a predictable, non-occluding position.
|
||||||
function labelAnchor(m: Measurement): Point {
|
function labelAnchor(m: Measurement): Point {
|
||||||
if (m.type === "line") {
|
if (m.type === "line") {
|
||||||
return { x: (m.a.x + m.b.x) / 2, y: (m.a.y + m.b.y) / 2 }
|
return { x: (m.a.x + m.b.x) / 2, y: (m.a.y + m.b.y) / 2 }
|
||||||
}
|
}
|
||||||
if (m.type === "rectangle") {
|
|
||||||
const [p0, p1, p2, p3] = m.corners
|
|
||||||
return {
|
|
||||||
x: (p0.x + p1.x + p2.x + p3.x) / 4,
|
|
||||||
y: (p0.y + p1.y + p2.y + p3.y) / 4,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (m.type === "ellipse") {
|
if (m.type === "ellipse") {
|
||||||
return m.center
|
return m.center
|
||||||
}
|
}
|
||||||
@ -438,8 +370,6 @@ function drawMeasurement(
|
|||||||
|
|
||||||
if (m.type === "line") {
|
if (m.type === "line") {
|
||||||
drawLineGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
|
drawLineGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
|
||||||
} else if (m.type === "rectangle") {
|
|
||||||
drawRectGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
|
|
||||||
} else if (m.type === "ellipse") {
|
} else if (m.type === "ellipse") {
|
||||||
drawEllipseGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
|
drawEllipseGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected)
|
||||||
} else {
|
} else {
|
||||||
@ -473,35 +403,6 @@ function drawLineGeometry(
|
|||||||
drawHandle(ctx, sb, handleColor, isSelected)
|
drawHandle(ctx, sb, handleColor, isSelected)
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawRectGeometry(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
m: RectMeasurement,
|
|
||||||
strokeColor: string,
|
|
||||||
handleColor: string,
|
|
||||||
lineWidth: number,
|
|
||||||
isSelected: boolean,
|
|
||||||
) {
|
|
||||||
const screenCorners = m.corners.map(imgToScreen)
|
|
||||||
ctx.beginPath()
|
|
||||||
for (let i = 0; i < screenCorners.length; i++) {
|
|
||||||
const p = screenCorners[i]
|
|
||||||
if (!p) continue
|
|
||||||
if (i === 0) ctx.moveTo(p.x, p.y)
|
|
||||||
else ctx.lineTo(p.x, p.y)
|
|
||||||
}
|
|
||||||
ctx.closePath()
|
|
||||||
ctx.strokeStyle = strokeColor
|
|
||||||
ctx.lineWidth = lineWidth
|
|
||||||
ctx.setLineDash(isSelected ? [] : [6, 3])
|
|
||||||
ctx.stroke()
|
|
||||||
ctx.setLineDash([])
|
|
||||||
// Don't fill the interior — keeps what's underneath visible, matching
|
|
||||||
// the line/ellipse/angle visual style.
|
|
||||||
for (const p of screenCorners) {
|
|
||||||
drawHandle(ctx, p, handleColor, isSelected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawEllipseGeometry(
|
function drawEllipseGeometry(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
m: EllipseMeasurement,
|
m: EllipseMeasurement,
|
||||||
@ -711,12 +612,6 @@ function drawPlacementPreview(ctx: CanvasRenderingContext2D) {
|
|||||||
ctx.moveTo(sPts[0].x, sPts[0].y)
|
ctx.moveTo(sPts[0].x, sPts[0].y)
|
||||||
ctx.lineTo(sCursor.x, sCursor.y)
|
ctx.lineTo(sCursor.x, sCursor.y)
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
} else if (activeTool.value === "rectangle" && sPts.length >= 1 && sPts[0] && sCursor) {
|
|
||||||
const a = sPts[0]
|
|
||||||
const b = sCursor
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.rect(a.x, a.y, b.x - a.x, b.y - a.y)
|
|
||||||
ctx.stroke()
|
|
||||||
} else if (activeTool.value === "ellipse" && sPts.length >= 1 && sPts[0]) {
|
} else if (activeTool.value === "ellipse" && sPts.length >= 1 && sPts[0]) {
|
||||||
const center = sPts[0]
|
const center = sPts[0]
|
||||||
const endA = sPts[1] ?? sCursor
|
const endA = sPts[1] ?? sCursor
|
||||||
@ -805,24 +700,6 @@ function pointToSegmentDistance(
|
|||||||
return Math.hypot(p.x - qx, p.y - qy)
|
return Math.hypot(p.x - qx, p.y - qy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard ray-cast point-in-polygon. Works for any simple quadrilateral
|
|
||||||
// (including reshaped non-rect cases) and gracefully degrades to a small
|
|
||||||
// or zero region for crossed quads — which is what we want, since a
|
|
||||||
// "crossed" rectangle is effectively user error.
|
|
||||||
function pointInPolygon(p: Point, poly: Point[]): boolean {
|
|
||||||
let inside = false
|
|
||||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i++) {
|
|
||||||
const pi = poly[i]
|
|
||||||
const pj = poly[j]
|
|
||||||
if (!pi || !pj) continue
|
|
||||||
const intersect =
|
|
||||||
pi.y > p.y !== pj.y > p.y &&
|
|
||||||
p.x < ((pj.x - pi.x) * (p.y - pi.y)) / (pj.y - pi.y) + pi.x
|
|
||||||
if (intersect) inside = !inside
|
|
||||||
}
|
|
||||||
return inside
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the min screen-space distance from cursor to the ellipse curve.
|
// Returns the min screen-space distance from cursor to the ellipse curve.
|
||||||
// Sampled parametrically; 96 samples is overkill for hit-testing but cheap.
|
// Sampled parametrically; 96 samples is overkill for hit-testing but cheap.
|
||||||
function ellipseCurveDistance(
|
function ellipseCurveDistance(
|
||||||
@ -866,14 +743,6 @@ function getHandlePositions(m: Measurement): { key: string; pt: Point }[] {
|
|||||||
{ key: "b", pt: m.b },
|
{ key: "b", pt: m.b },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
if (m.type === "rectangle") {
|
|
||||||
return [
|
|
||||||
{ key: "c0", pt: m.corners[0] },
|
|
||||||
{ key: "c1", pt: m.corners[1] },
|
|
||||||
{ key: "c2", pt: m.corners[2] },
|
|
||||||
{ key: "c3", pt: m.corners[3] },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
if (m.type === "ellipse") {
|
if (m.type === "ellipse") {
|
||||||
return [
|
return [
|
||||||
{ key: "center", pt: m.center },
|
{ key: "center", pt: m.center },
|
||||||
@ -934,23 +803,6 @@ function hitTest(cursorScreen: Point): HitResult | null {
|
|||||||
if (pointToSegmentDistance(cursorScreen, sa, sb) <= LINE_HIT_PX) {
|
if (pointToSegmentDistance(cursorScreen, sa, sb) <= LINE_HIT_PX) {
|
||||||
return { measurementId: m.id, kind: "geometry", handleKey: null }
|
return { measurementId: m.id, kind: "geometry", handleKey: null }
|
||||||
}
|
}
|
||||||
} else if (m.type === "rectangle") {
|
|
||||||
const sc = m.corners.map(imgToScreen)
|
|
||||||
// Edge-near test for thin grabs along the border.
|
|
||||||
let edgeHit = false
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
const a = sc[i]
|
|
||||||
const b = sc[(i + 1) % 4]
|
|
||||||
if (a && b && pointToSegmentDistance(cursorScreen, a, b) <= LINE_HIT_PX) {
|
|
||||||
edgeHit = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Interior-fill test so a big rect is grabbable from anywhere
|
|
||||||
// inside, not just along the 6px edge band.
|
|
||||||
if (edgeHit || pointInPolygon(cursorScreen, sc)) {
|
|
||||||
return { measurementId: m.id, kind: "geometry", handleKey: null }
|
|
||||||
}
|
|
||||||
} else if (m.type === "ellipse") {
|
} else if (m.type === "ellipse") {
|
||||||
if (ellipseCurveDistance(cursorScreen, m) <= ELLIPSE_HIT_PX) {
|
if (ellipseCurveDistance(cursorScreen, m) <= ELLIPSE_HIT_PX) {
|
||||||
return { measurementId: m.id, kind: "geometry", handleKey: null }
|
return { measurementId: m.id, kind: "geometry", handleKey: null }
|
||||||
@ -986,28 +838,6 @@ function commitPlacement() {
|
|||||||
colorCounter += 1
|
colorCounter += 1
|
||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
placementPoints.value = []
|
placementPoints.value = []
|
||||||
} else if (activeTool.value === "rectangle" && pts.length === 2) {
|
|
||||||
const [p1, p2] = pts as [Point, Point]
|
|
||||||
// Normalise so corners are TL/TR/BR/BL regardless of click order.
|
|
||||||
const minX = Math.min(p1.x, p2.x)
|
|
||||||
const maxX = Math.max(p1.x, p2.x)
|
|
||||||
const minY = Math.min(p1.y, p2.y)
|
|
||||||
const maxY = Math.max(p1.y, p2.y)
|
|
||||||
const id = nanoid()
|
|
||||||
measurements.value.push({
|
|
||||||
id,
|
|
||||||
type: "rectangle",
|
|
||||||
colorIndex: colorCounter,
|
|
||||||
corners: [
|
|
||||||
{ x: minX, y: minY },
|
|
||||||
{ x: maxX, y: minY },
|
|
||||||
{ x: maxX, y: maxY },
|
|
||||||
{ x: minX, y: maxY },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
colorCounter += 1
|
|
||||||
selectedId.value = id
|
|
||||||
placementPoints.value = []
|
|
||||||
} else if (activeTool.value === "ellipse" && pts.length === 3) {
|
} else if (activeTool.value === "ellipse" && pts.length === 3) {
|
||||||
const [center, axisEndA, axisEndB] = pts as [Point, Point, Point]
|
const [center, axisEndA, axisEndB] = pts as [Point, Point, Point]
|
||||||
const id = nanoid()
|
const id = nanoid()
|
||||||
@ -1042,8 +872,7 @@ function commitPlacement() {
|
|||||||
function handlePlacementClick(imgPt: Point) {
|
function handlePlacementClick(imgPt: Point) {
|
||||||
if (activeTool.value === "none") return
|
if (activeTool.value === "none") return
|
||||||
placementPoints.value.push(imgPt)
|
placementPoints.value.push(imgPt)
|
||||||
const needed =
|
const needed = activeTool.value === "line" ? 2 : 3
|
||||||
activeTool.value === "line" || activeTool.value === "rectangle" ? 2 : 3
|
|
||||||
if (placementPoints.value.length >= needed) {
|
if (placementPoints.value.length >= needed) {
|
||||||
commitPlacement()
|
commitPlacement()
|
||||||
}
|
}
|
||||||
@ -1090,17 +919,6 @@ function cloneMeasurement(m: Measurement): Measurement {
|
|||||||
if (m.type === "line") {
|
if (m.type === "line") {
|
||||||
return { ...m, a: { ...m.a }, b: { ...m.b } }
|
return { ...m, a: { ...m.a }, b: { ...m.b } }
|
||||||
}
|
}
|
||||||
if (m.type === "rectangle") {
|
|
||||||
return {
|
|
||||||
...m,
|
|
||||||
corners: [
|
|
||||||
{ ...m.corners[0] },
|
|
||||||
{ ...m.corners[1] },
|
|
||||||
{ ...m.corners[2] },
|
|
||||||
{ ...m.corners[3] },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (m.type === "ellipse") {
|
if (m.type === "ellipse") {
|
||||||
return {
|
return {
|
||||||
...m,
|
...m,
|
||||||
@ -1132,17 +950,6 @@ function applyDrag(
|
|||||||
b: { x: original.b.x + dx, y: original.b.y + dy },
|
b: { x: original.b.x + dx, y: original.b.y + dy },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (original.type === "rectangle") {
|
|
||||||
return {
|
|
||||||
...original,
|
|
||||||
corners: [
|
|
||||||
{ x: original.corners[0].x + dx, y: original.corners[0].y + dy },
|
|
||||||
{ x: original.corners[1].x + dx, y: original.corners[1].y + dy },
|
|
||||||
{ x: original.corners[2].x + dx, y: original.corners[2].y + dy },
|
|
||||||
{ x: original.corners[3].x + dx, y: original.corners[3].y + dy },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (original.type === "ellipse") {
|
if (original.type === "ellipse") {
|
||||||
return {
|
return {
|
||||||
...original,
|
...original,
|
||||||
@ -1162,48 +969,6 @@ function applyDrag(
|
|||||||
if (original.type === "line") {
|
if (original.type === "line") {
|
||||||
if (handleKey === "a") return { ...original, a: { x: original.a.x + dx, y: original.a.y + dy } }
|
if (handleKey === "a") return { ...original, a: { x: original.a.x + dx, y: original.a.y + dy } }
|
||||||
if (handleKey === "b") return { ...original, b: { x: original.b.x + dx, y: original.b.y + dy } }
|
if (handleKey === "b") return { ...original, b: { x: original.b.x + dx, y: original.b.y + dy } }
|
||||||
} else if (original.type === "rectangle") {
|
|
||||||
// Constrain to an axis-aligned rectangle: the dragged corner
|
|
||||||
// follows the cursor, the diagonally-opposite corner stays put,
|
|
||||||
// and the two adjacent corners are recomputed from the cross of
|
|
||||||
// (dragged.x, opp.y) and (opp.x, dragged.y) so the shape stays
|
|
||||||
// rectangular. Corner indices stay stable — c0 is still the
|
|
||||||
// logical TL even if the box flips through itself.
|
|
||||||
const cornerIdx: 0 | 1 | 2 | 3 | null =
|
|
||||||
handleKey === "c0" ? 0 :
|
|
||||||
handleKey === "c1" ? 1 :
|
|
||||||
handleKey === "c2" ? 2 :
|
|
||||||
handleKey === "c3" ? 3 : null
|
|
||||||
if (cornerIdx !== null) {
|
|
||||||
const moving = {
|
|
||||||
x: original.corners[cornerIdx].x + dx,
|
|
||||||
y: original.corners[cornerIdx].y + dy,
|
|
||||||
}
|
|
||||||
const oppIdx = ((cornerIdx + 2) % 4) as 0 | 1 | 2 | 3
|
|
||||||
const opp = { ...original.corners[oppIdx] }
|
|
||||||
const next: [Point, Point, Point, Point] = [
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
]
|
|
||||||
next[cornerIdx] = moving
|
|
||||||
next[oppIdx] = opp
|
|
||||||
if (cornerIdx === 0 || cornerIdx === 2) {
|
|
||||||
// TL ↔ BR diagonal: TR=(BR.x, TL.y), BL=(TL.x, BR.y).
|
|
||||||
const tl = next[0]
|
|
||||||
const br = next[2]
|
|
||||||
next[1] = { x: br.x, y: tl.y }
|
|
||||||
next[3] = { x: tl.x, y: br.y }
|
|
||||||
} else {
|
|
||||||
// TR ↔ BL diagonal: TL=(BL.x, TR.y), BR=(TR.x, BL.y).
|
|
||||||
const tr = next[1]
|
|
||||||
const bl = next[3]
|
|
||||||
next[0] = { x: bl.x, y: tr.y }
|
|
||||||
next[2] = { x: tr.x, y: bl.y }
|
|
||||||
}
|
|
||||||
return { ...original, corners: next }
|
|
||||||
}
|
|
||||||
} else if (original.type === "ellipse") {
|
} else if (original.type === "ellipse") {
|
||||||
if (handleKey === "center") {
|
if (handleKey === "center") {
|
||||||
// Dragging the ellipse center translates the whole ellipse so
|
// Dragging the ellipse center translates the whole ellipse so
|
||||||
@ -1249,24 +1014,25 @@ function updateMeasurement(id: string, next: Measurement) {
|
|||||||
measurements.value[idx] = next
|
measurements.value[idx] = next
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hit-test-first press handler, mirroring Konva's behaviour in the datum
|
function pointerDown(screenX: number, screenY: number): "measurement" | "pan" {
|
||||||
// editor: a click on an existing shape always wins over the stage's own
|
|
||||||
// drag/pan, AND over any active placement tool. Returns "measurement" if
|
|
||||||
// we picked up an existing measurement (caller suppresses the trailing
|
|
||||||
// click so a placement tool doesn't commit a spurious point), "pan" if
|
|
||||||
// the press should start a stage pan, and "placement" if the press
|
|
||||||
// landed on empty space while a placement tool is active (caller does
|
|
||||||
// nothing — the click event will commit the placement).
|
|
||||||
function pointerDown(
|
|
||||||
screenX: number,
|
|
||||||
screenY: number,
|
|
||||||
): "measurement" | "pan" | "placement" {
|
|
||||||
const cursor = { x: screenX, y: screenY }
|
const cursor = { x: screenX, y: screenY }
|
||||||
const hit = hitTest(cursor)
|
const hit = hitTest(cursor)
|
||||||
if (hit) {
|
if (!hit) {
|
||||||
|
// Empty-space click: deselect, fall through to pan behavior.
|
||||||
|
if (selectedId.value !== null) {
|
||||||
|
selectedId.value = null
|
||||||
|
drawOverlay()
|
||||||
|
}
|
||||||
|
return "pan"
|
||||||
|
}
|
||||||
|
|
||||||
selectedId.value = hit.measurementId
|
selectedId.value = hit.measurementId
|
||||||
const target = measurements.value.find((m) => m.id === hit.measurementId)
|
const target = measurements.value.find((m) => m.id === hit.measurementId)
|
||||||
if (target) {
|
if (!target) {
|
||||||
|
drawOverlay()
|
||||||
|
return "measurement"
|
||||||
|
}
|
||||||
|
|
||||||
const mode: DragMode = hit.kind === "handle" ? "handle" : "move"
|
const mode: DragMode = hit.kind === "handle" ? "handle" : "move"
|
||||||
dragState = {
|
dragState = {
|
||||||
mode,
|
mode,
|
||||||
@ -1274,18 +1040,10 @@ function pointerDown(
|
|||||||
handleKey: hit.handleKey,
|
handleKey: hit.handleKey,
|
||||||
startImg: screenToImg(screenX, screenY),
|
startImg: screenToImg(screenX, screenY),
|
||||||
startSnapshot: cloneMeasurement(target),
|
startSnapshot: cloneMeasurement(target),
|
||||||
}
|
moved: false,
|
||||||
}
|
}
|
||||||
drawOverlay()
|
drawOverlay()
|
||||||
return "measurement"
|
return "measurement"
|
||||||
}
|
|
||||||
// Empty-space press.
|
|
||||||
if (activeTool.value !== "none") return "placement"
|
|
||||||
if (selectedId.value !== null) {
|
|
||||||
selectedId.value = null
|
|
||||||
drawOverlay()
|
|
||||||
}
|
|
||||||
return "pan"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pointerMove(screenX: number, screenY: number): boolean {
|
function pointerMove(screenX: number, screenY: number): boolean {
|
||||||
@ -1293,6 +1051,14 @@ function pointerMove(screenX: number, screenY: number): boolean {
|
|||||||
const nowImg = screenToImg(screenX, screenY)
|
const nowImg = screenToImg(screenX, screenY)
|
||||||
const dxImg = nowImg.x - dragState.startImg.x
|
const dxImg = nowImg.x - dragState.startImg.x
|
||||||
const dyImg = nowImg.y - dragState.startImg.y
|
const dyImg = nowImg.y - dragState.startImg.y
|
||||||
|
if (!dragState.moved) {
|
||||||
|
// Convert image-space delta back to screen-space via viewScale; easier
|
||||||
|
// than tracking the original screen cursor separately.
|
||||||
|
const screenDx = dxImg * viewScale.value
|
||||||
|
const screenDy = dyImg * viewScale.value
|
||||||
|
if (Math.hypot(screenDx, screenDy) < DRAG_THRESHOLD_PX) return true
|
||||||
|
dragState.moved = true
|
||||||
|
}
|
||||||
const next = applyDrag(
|
const next = applyDrag(
|
||||||
dragState.startSnapshot,
|
dragState.startSnapshot,
|
||||||
dragState.mode,
|
dragState.mode,
|
||||||
@ -1327,94 +1093,55 @@ function onWheel(e: WheelEvent) {
|
|||||||
redraw()
|
redraw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once a press starts on the canvas we listen at the window level so the
|
|
||||||
// drag survives the cursor leaving the canvas (or moving faster than the
|
|
||||||
// browser's canvas-bound event firing). Re-attached on each press, removed
|
|
||||||
// when the drag/pan ends.
|
|
||||||
function attachWindowDragListeners() {
|
|
||||||
window.addEventListener("mousemove", onWindowMouseMove)
|
|
||||||
window.addEventListener("mouseup", onWindowMouseUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
function detachWindowDragListeners() {
|
|
||||||
window.removeEventListener("mousemove", onWindowMouseMove)
|
|
||||||
window.removeEventListener("mouseup", onWindowMouseUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// True between an existing-measurement-grab on mousedown and the trailing
|
|
||||||
// click event the browser will fire. Suppresses the placement-tool click
|
|
||||||
// so grabbing a prior measurement doesn't commit a spurious new point.
|
|
||||||
let suppressNextClick = false
|
|
||||||
|
|
||||||
function onMouseDown(e: MouseEvent) {
|
function onMouseDown(e: MouseEvent) {
|
||||||
const { x, y } = getCanvasXY(e)
|
const { x, y } = getCanvasXY(e)
|
||||||
suppressNextClick = false
|
if (activeTool.value !== "none") {
|
||||||
|
// Placement tools ignore mousedown; they commit on click so a user
|
||||||
|
// can drag-scroll accidentally without placing a spurious point.
|
||||||
|
return
|
||||||
|
}
|
||||||
const outcome = pointerDown(x, y)
|
const outcome = pointerDown(x, y)
|
||||||
if (outcome === "measurement") {
|
if (outcome === "pan") {
|
||||||
suppressNextClick = true
|
|
||||||
} else if (outcome === "pan") {
|
|
||||||
isPanning = true
|
isPanning = true
|
||||||
panStart = { x: e.clientX - viewOffsetX.value, y: e.clientY - viewOffsetY.value }
|
panStart = { x: e.clientX - viewOffsetX.value, y: e.clientY - viewOffsetY.value }
|
||||||
}
|
}
|
||||||
if (dragState || isPanning) attachWindowDragListeners()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWindowMouseMove(e: MouseEvent) {
|
function onMouseMove(e: MouseEvent) {
|
||||||
if (dragState) {
|
if (dragState) {
|
||||||
const { x, y } = getCanvasXY(e)
|
const { x, y } = getCanvasXY(e)
|
||||||
pointerMove(x, y)
|
pointerMove(x, y)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isPanning) {
|
|
||||||
viewOffsetX.value = e.clientX - panStart.x
|
|
||||||
viewOffsetY.value = e.clientY - panStart.y
|
|
||||||
redraw()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onWindowMouseUp() {
|
|
||||||
pointerUp()
|
|
||||||
isPanning = false
|
|
||||||
detachWindowDragListeners()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent) {
|
|
||||||
// While a drag/pan is in flight the window listener handles motion;
|
|
||||||
// here we only need the placement-preview cursor.
|
|
||||||
if (dragState || isPanning) return
|
|
||||||
if (activeTool.value !== "none") {
|
if (activeTool.value !== "none") {
|
||||||
const { x, y } = getCanvasXY(e)
|
const { x, y } = getCanvasXY(e)
|
||||||
placementCursor.value = screenToImg(x, y)
|
placementCursor.value = screenToImg(x, y)
|
||||||
drawOverlay()
|
drawOverlay()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if (!isPanning) return
|
||||||
|
viewOffsetX.value = e.clientX - panStart.x
|
||||||
|
viewOffsetY.value = e.clientY - panStart.y
|
||||||
|
redraw()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseUp() {
|
function onMouseUp() {
|
||||||
// Mouseup that lands inside the canvas — covered by the window listener
|
|
||||||
// too, but we keep this so a quick click without movement still ends
|
|
||||||
// cleanly even if for some reason the window handler misses.
|
|
||||||
if (dragState || isPanning) {
|
|
||||||
pointerUp()
|
pointerUp()
|
||||||
isPanning = false
|
isPanning = false
|
||||||
detachWindowDragListeners()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseLeave() {
|
function onMouseLeave() {
|
||||||
// Don't end the drag here — the window listener takes over while the
|
pointerUp()
|
||||||
// cursor is outside the canvas. Just clear the placement preview.
|
isPanning = false
|
||||||
placementCursor.value = null
|
placementCursor.value = null
|
||||||
drawOverlay()
|
drawOverlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClick(e: MouseEvent) {
|
function onClick(e: MouseEvent) {
|
||||||
if (suppressNextClick) {
|
|
||||||
// Press picked up an existing measurement — the trailing click
|
|
||||||
// is part of that gesture, not a placement.
|
|
||||||
suppressNextClick = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (activeTool.value === "none") return
|
if (activeTool.value === "none") return
|
||||||
|
// Guard against the click event that always follows a mousedown: if the
|
||||||
|
// user panned or dragged, that wasn't a placement click. Here we have no
|
||||||
|
// dragState at click-time because placements don't start one.
|
||||||
const { x, y } = getCanvasXY(e)
|
const { x, y } = getCanvasXY(e)
|
||||||
const imgPt = screenToImg(x, y)
|
const imgPt = screenToImg(x, y)
|
||||||
handlePlacementClick(imgPt)
|
handlePlacementClick(imgPt)
|
||||||
@ -1436,15 +1163,12 @@ function onTouchStart(e: TouchEvent) {
|
|||||||
isPanning = false
|
isPanning = false
|
||||||
} else if (e.touches.length === 1 && t0) {
|
} else if (e.touches.length === 1 && t0) {
|
||||||
const { x, y } = getCanvasXY(t0)
|
const { x, y } = getCanvasXY(t0)
|
||||||
// Always hit-test first so a tap on an existing handle reshapes
|
if (activeTool.value !== "none") {
|
||||||
// it even when a placement tool is active.
|
|
||||||
suppressNextClick = false
|
|
||||||
const outcome = pointerDown(x, y)
|
|
||||||
if (outcome === "measurement") {
|
|
||||||
suppressNextClick = true
|
|
||||||
} else if (outcome === "placement") {
|
|
||||||
placementCursor.value = screenToImg(x, y)
|
placementCursor.value = screenToImg(x, y)
|
||||||
} else if (outcome === "pan") {
|
return
|
||||||
|
}
|
||||||
|
const outcome = pointerDown(x, y)
|
||||||
|
if (outcome === "pan") {
|
||||||
isPanning = true
|
isPanning = true
|
||||||
panStart = {
|
panStart = {
|
||||||
x: t0.clientX - viewOffsetX.value,
|
x: t0.clientX - viewOffsetX.value,
|
||||||
@ -1542,10 +1266,6 @@ const placementHint = computed<string | null>(() => {
|
|||||||
if (n === 0) return "Click the first endpoint."
|
if (n === 0) return "Click the first endpoint."
|
||||||
return "Click the second endpoint."
|
return "Click the second endpoint."
|
||||||
}
|
}
|
||||||
if (activeTool.value === "rectangle") {
|
|
||||||
if (n === 0) return "Click the first corner."
|
|
||||||
return "Click the opposite corner."
|
|
||||||
}
|
|
||||||
if (activeTool.value === "ellipse") {
|
if (activeTool.value === "ellipse") {
|
||||||
if (n === 0) return "Click the ellipse center."
|
if (n === 0) return "Click the ellipse center."
|
||||||
if (n === 1) return "Click the first semi-axis endpoint."
|
if (n === 1) return "Click the first semi-axis endpoint."
|
||||||
@ -1561,7 +1281,7 @@ const measurementSummaries = computed(() => {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
type: m.type,
|
type: m.type,
|
||||||
typeLabel: measurementTypeLabel(m),
|
typeLabel: measurementTypeLabel(m),
|
||||||
label: measurementSummaryValue(m),
|
label: measurementLabel(m),
|
||||||
color: getDatumColor(m.colorIndex),
|
color: getDatumColor(m.colorIndex),
|
||||||
selected: m.id === selectedId.value,
|
selected: m.id === selectedId.value,
|
||||||
}))
|
}))
|
||||||
@ -1671,7 +1391,6 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
resizeObs?.disconnect()
|
resizeObs?.disconnect()
|
||||||
window.removeEventListener("keydown", onKeyDown)
|
window.removeEventListener("keydown", onKeyDown)
|
||||||
detachWindowDragListeners()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.imageUrl, loadImg)
|
watch(() => props.imageUrl, loadImg)
|
||||||
@ -1735,30 +1454,6 @@ watch(() => props.scalePxPerMm, () => { drawOverlay() })
|
|||||||
</svg>
|
</svg>
|
||||||
Ellipse
|
Ellipse
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium transition-colors"
|
|
||||||
:class="
|
|
||||||
activeTool === 'rectangle'
|
|
||||||
? 'bg-primary/10 text-primary'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
"
|
|
||||||
@click="setTool('rectangle')"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="4" y="6" width="16" height="12" rx="1" />
|
|
||||||
</svg>
|
|
||||||
Rect
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium transition-colors"
|
class="inline-flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium transition-colors"
|
||||||
:class="
|
:class="
|
||||||
|
|||||||
@ -806,11 +806,7 @@ async function download() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Corrected image with tools — full-bleed to use the whole page width
|
<!-- Corrected image with tools -->
|
||||||
even though the surrounding column is capped at max-w-4xl. -->
|
|
||||||
<div
|
|
||||||
class="relative left-1/2 w-screen -translate-x-1/2 px-4"
|
|
||||||
>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-base"
|
<CardTitle class="text-base"
|
||||||
@ -824,7 +820,6 @@ async function download() {
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Download -->
|
<!-- Download -->
|
||||||
<div class="flex flex-col items-center gap-3 pb-8">
|
<div class="flex flex-col items-center gap-3 pb-8">
|
||||||
|
|||||||
@ -10,14 +10,271 @@ withDefaults(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<svg
|
||||||
class="inline-flex items-center justify-center leading-none select-none"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
:style="{
|
viewBox="0 0 64 64"
|
||||||
width: size + 'px',
|
:width="size"
|
||||||
height: size + 'px',
|
:height="size"
|
||||||
fontSize: Math.round(size * 0.9) + 'px',
|
aria-label="Skwik squirrel logo"
|
||||||
}"
|
>
|
||||||
aria-label="Skwik logo"
|
<!-- Tail (fluffy, curling up behind) -->
|
||||||
role="img"
|
<path
|
||||||
>📐</span>
|
d="M50 52 Q58 40 54 28 Q52 22 48 20
|
||||||
|
Q55 18 56 12 Q56 8 52 10
|
||||||
|
Q48 12 46 18"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
opacity="0.7"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<ellipse
|
||||||
|
cx="32"
|
||||||
|
cy="46"
|
||||||
|
rx="12"
|
||||||
|
ry="10"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Head -->
|
||||||
|
<ellipse
|
||||||
|
cx="32"
|
||||||
|
cy="28"
|
||||||
|
rx="12"
|
||||||
|
ry="13"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Ear tufts -->
|
||||||
|
<path
|
||||||
|
d="M22 18 Q18 10 14 12 Q12 16 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M42 18 Q46 10 50 12 Q52 16 44 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Eyes -->
|
||||||
|
<circle
|
||||||
|
cx="27"
|
||||||
|
cy="26"
|
||||||
|
r="3"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="37"
|
||||||
|
cy="26"
|
||||||
|
r="3"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<!-- Eye shine -->
|
||||||
|
<circle cx="28.2" cy="25" r="1" fill="var(--background, #fff)" />
|
||||||
|
<circle cx="38.2" cy="25" r="1" fill="var(--background, #fff)" />
|
||||||
|
|
||||||
|
<!-- Nose -->
|
||||||
|
<ellipse
|
||||||
|
cx="32"
|
||||||
|
cy="32"
|
||||||
|
rx="2"
|
||||||
|
ry="1.5"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Cheeks -->
|
||||||
|
<ellipse
|
||||||
|
cx="24"
|
||||||
|
cy="32"
|
||||||
|
rx="4"
|
||||||
|
ry="3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.3"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="40"
|
||||||
|
cy="32"
|
||||||
|
rx="4"
|
||||||
|
ry="3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Mouth -->
|
||||||
|
<path
|
||||||
|
d="M30 34 Q32 36 34 34"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Arms holding ruler -->
|
||||||
|
<path
|
||||||
|
d="M20 42 L12 38"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M44 42 L52 38"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Ruler (held diagonally) -->
|
||||||
|
<rect
|
||||||
|
x="4"
|
||||||
|
y="34"
|
||||||
|
width="22"
|
||||||
|
height="4"
|
||||||
|
rx="0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
transform="rotate(-15 15 36)"
|
||||||
|
/>
|
||||||
|
<!-- Ruler tick marks -->
|
||||||
|
<line
|
||||||
|
x1="8" y1="34" x2="8" y2="36"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
transform="rotate(-15 15 36)"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12" y1="34" x2="12" y2="36"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
transform="rotate(-15 15 36)"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="16" y1="34" x2="16" y2="36"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
transform="rotate(-15 15 36)"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="20" y1="34" x2="20" y2="36"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
transform="rotate(-15 15 36)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Safety goggles on forehead -->
|
||||||
|
<path
|
||||||
|
d="M22 20 Q27 17 32 18 Q37 17 42 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<!-- Goggle lenses -->
|
||||||
|
<ellipse
|
||||||
|
cx="26"
|
||||||
|
cy="20"
|
||||||
|
rx="3.5"
|
||||||
|
ry="2.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="38"
|
||||||
|
cy="20"
|
||||||
|
rx="3.5"
|
||||||
|
ry="2.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
<!-- Goggle bridge -->
|
||||||
|
<path
|
||||||
|
d="M29.5 20 Q32 19 34.5 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
stroke-width="1.2"
|
||||||
|
/>
|
||||||
|
<!-- Goggle lens tint -->
|
||||||
|
<ellipse
|
||||||
|
cx="26"
|
||||||
|
cy="20"
|
||||||
|
rx="2.5"
|
||||||
|
ry="1.5"
|
||||||
|
fill="#f59e0b"
|
||||||
|
opacity="0.15"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="38"
|
||||||
|
cy="20"
|
||||||
|
rx="2.5"
|
||||||
|
ry="1.5"
|
||||||
|
fill="#f59e0b"
|
||||||
|
opacity="0.15"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Hard hat -->
|
||||||
|
<path
|
||||||
|
d="M18 16 Q18 6 32 5 Q46 6 46 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<!-- Hat brim -->
|
||||||
|
<path
|
||||||
|
d="M16 16 L48 16"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<!-- Hat dome highlight -->
|
||||||
|
<path
|
||||||
|
d="M26 9 Q32 7 38 9"
|
||||||
|
fill="none"
|
||||||
|
stroke="#fbbf24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
opacity="0.6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Feet -->
|
||||||
|
<ellipse
|
||||||
|
cx="26"
|
||||||
|
cy="56"
|
||||||
|
rx="4"
|
||||||
|
ry="2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="38"
|
||||||
|
cy="56"
|
||||||
|
rx="4"
|
||||||
|
ry="2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user