diff --git a/src/export/planToPrintSvg.ts b/src/export/planToPrintSvg.ts index 5baa5fd..c9cf3bb 100644 --- a/src/export/planToPrintSvg.ts +++ b/src/export/planToPrintSvg.ts @@ -24,6 +24,7 @@ import type { HatchRender, Plan, Primitive } from "../plan/generatePlan"; import type { Vec2 } from "../model/types"; import { buildRandomHatchRuns, zigzagPoints, motifPoints } from "../plan/glPlan/glPlanHatch"; +import { dashHasDot } from "../ui/lineSegments"; /** SVG-Namespace für die programmatisch erzeugten Knoten. */ const SVG_NS = "http://www.w3.org/2000/svg"; @@ -403,6 +404,8 @@ function arcNode( el.setAttribute("stroke-width", String(quantizePen(p.weightMm))); if (p.dash && p.dash.length) { el.setAttribute("stroke-dasharray", p.dash.map((x) => round(x)).join(" ")); + // Punktmuster (dash enthält eine 0): runde Kappe → Dots am Bogen. + if (dashHasDot(p.dash)) el.setAttribute("stroke-linecap", "round"); } return el; } diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 2de63c9..e60fac6 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -572,10 +572,21 @@ export const de = { "resources.random.reroll": "Neu würfeln", "resources.stroke.solid": "Vollinie", "resources.stroke.dash": "Strich", - "resources.dash.segments": "Segmente (mm)", - "resources.dash.hint": "Alternierend Strich/Lücke in mm (z. B. 1, 2 = 1 mm Strich, 2 mm Lücke).", + "resources.dash.segments": "Segmente", + "resources.dash.hint": "Geordnete, loopende Folge aus Strich/Punkt/Lücke (mm). Die letzte Lücke ist der Abstand zum nächsten Loop.", "resources.dash.add": "Segment", "resources.dash.remove": "Segment entfernen", + "resources.linePreset.label": "Muster", + "resources.linePreset.solid": "Vollinie", + "resources.linePreset.dash": "Strichlinie", + "resources.linePreset.dot": "Punktlinie", + "resources.linePreset.dashDot": "Strich-Punkt", + "resources.seg.dash": "Strich", + "resources.seg.dot": "Punkt", + "resources.seg.gap": "Lücke", + "resources.seg.addDash": "+ Strich", + "resources.seg.addDot": "+ Punkt", + "resources.seg.addGap": "+ Lücke", "resources.hatchKind.vector": "Vektor", "resources.hatchKind.image": "Bild", "resources.lines.parallel": "Parallel", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6260d8c..1c3c3d2 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -567,10 +567,21 @@ export const en: Record = { "resources.random.reroll": "Re-roll", "resources.stroke.solid": "Solid", "resources.stroke.dash": "Dash", - "resources.dash.segments": "Segments (mm)", - "resources.dash.hint": "Alternating dash/gap in mm (e.g. 1, 2 = 1 mm dash, 2 mm gap).", + "resources.dash.segments": "Segments", + "resources.dash.hint": "Ordered, looping sequence of dash/dot/gap (mm). The last gap is the spacing to the next loop.", "resources.dash.add": "Segment", "resources.dash.remove": "Remove segment", + "resources.linePreset.label": "Pattern", + "resources.linePreset.solid": "Solid", + "resources.linePreset.dash": "Dashed", + "resources.linePreset.dot": "Dotted", + "resources.linePreset.dashDot": "Dash-dot", + "resources.seg.dash": "Dash", + "resources.seg.dot": "Dot", + "resources.seg.gap": "Gap", + "resources.seg.addDash": "+ Dash", + "resources.seg.addDot": "+ Dot", + "resources.seg.addGap": "+ Gap", "resources.hatchKind.vector": "Vector", "resources.hatchKind.image": "Image", "resources.lines.parallel": "Parallel", diff --git a/src/model/types.ts b/src/model/types.ts index 0689260..e66147e 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -42,7 +42,15 @@ export interface LineStyle { weight: number; /** Farbe (hex). */ color: string; - /** Strichmuster in Millimetern; `null` = durchgezogen. */ + /** + * Strichmuster in Millimetern, alternierend AN/AUS (`[on, off, on, off, …]`), + * loopend; `null` = durchgezogen (Volllinie). Konvention des modularen + * Segment-Systems (siehe `src/ui/lineSegments.ts`): ein AN-Wert von 0 bedeutet + * einen PUNKT (Dot) — er wird nur mit runder Strichkappe sichtbar (die + * betroffene Linie erhält dann `stroke-linecap: round`). So bilden sich + * Volllinie/Strichlinie/Punktlinie/Strich-Punkt und frei modulare Folgen aus + * Strich/Punkt/Lücke ab. Additiv — kein zusätzliches Feld nötig. + */ dash: number[] | null; /** * Linien-Typ (additiv, Default `undefined` ⇒ "dash"): diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 94ce608..2756657 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -21,6 +21,7 @@ import { useGlPlanRenderer } from "./useGlPlanRenderer"; import { useWasmPlanRenderer } from "./useWasmPlanRenderer"; import { quantizePen } from "../export/sceneToPrintSvg"; import { buildRandomHatchRuns, zigzagPoints, motifPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch"; +import { dashHasDot } from "../ui/lineSegments"; const PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum) const PAD = 60; // Rand in viewBox-Einheiten @@ -2423,6 +2424,8 @@ function HatchPattern({ .map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm))) .join(" ") : undefined; + // Punktmuster in den Musterlinien: runde Kappe → Dots (Punkt/Strich-Punkt). + const dashCap = dashHasDot(hatch.dash) ? ("round" as const) : undefined; if (hatch.kind === "image" && hatch.image) { // Bild-Schraffur: gekacheltes -Muster. Kachel-Basismaß in viewBox- @@ -2468,6 +2471,7 @@ function HatchPattern({ stroke={hatch.color} strokeWidth={sw} strokeDasharray={dashes} + strokeLinecap={dashCap} vectorEffect={vfx} /> @@ -2493,6 +2497,7 @@ function HatchPattern({ stroke={hatch.color} strokeWidth={sw} strokeDasharray={dashes} + strokeLinecap={dashCap} vectorEffect={vfx} /> {hatch.pattern === "crosshatch" && ( @@ -2504,6 +2509,7 @@ function HatchPattern({ stroke={hatch.color} strokeWidth={sw} strokeDasharray={dashes} + strokeLinecap={dashCap} vectorEffect={vfx} /> )} @@ -2668,6 +2674,9 @@ function DrawingRunShape({ ? motifPoints(run.pts[0], run.pts[run.pts.length - 1], run.motif, DASH_MM_TO_M) : run.pts; const pts = modelPts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" "); + // Punktmuster (dash enthält eine 0): runde Kappe, damit die 0-Längen-Segmente + // als Dots erscheinen (Punktlinie / Strich-Punkt). + const cap = dashHasDot(run.dash) ? ("round" as const) : undefined; const shape = run.closed ? ( ) : ( @@ -2686,6 +2696,7 @@ function DrawingRunShape({ stroke={run.color} strokeWidth={sw} strokeDasharray={dashStr} + strokeLinecap={cap} vectorEffect={vfx} /> ); @@ -2912,6 +2923,7 @@ function renderPrimitive( stroke={p.color} strokeWidth={weight(p.weightMm)} strokeDasharray={dashOf(p.dash)} + strokeLinecap={dashHasDot(p.dash) ? "round" : undefined} vectorEffect={vfx} /> ); @@ -2934,6 +2946,7 @@ function renderPrimitive( className={p.cls} strokeWidth={weight(p.weightMm)} strokeDasharray={dashOf(p.dash)} + strokeLinecap={dashHasDot(p.dash) ? "round" : undefined} vectorEffect={vfx} /> ); diff --git a/src/styles.css b/src/styles.css index bad1714..17c3913 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2569,6 +2569,35 @@ body { border-radius: 3px; } +/* Modularer Linien-Segment-Editor (Strich / Punkt / Lücke). */ +.line-seg-editor { + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-start; +} +.line-seg-row { + display: flex; + align-items: center; + gap: 4px; +} +.line-seg-num { + width: 56px; + display: inline-flex; + align-items: center; +} +.line-seg-dotlabel { + color: var(--muted); + font-variant-numeric: tabular-nums; + padding-left: 4px; +} +.line-seg-add { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 2px; +} + /* ══ Panel-Chrome (Andockrahmen: Docks, Tabs, Panel-Rahmen) ═══════════════════ Der erweiterbare Rahmen: links/rechts je ein Dock mit Tab-Leiste und dem aktiven Panel. Greift dieselben Tokens wie die nav- und res-Klassen (dunkles DOSSIER- diff --git a/src/ui/ResourceManager.tsx b/src/ui/ResourceManager.tsx index 029e663..fcbe91a 100644 --- a/src/ui/ResourceManager.tsx +++ b/src/ui/ResourceManager.tsx @@ -43,6 +43,15 @@ import { import { requestMaterialPreview, cancelMaterialPreview } from "../materials/spherePreview"; import { EyeIcon } from "./EyeIcon"; import { HatchSwatch, LineSwatch } from "./hatchPreview"; +import { + segmentsToDash, + dashToSegments, + presetOfDash, + LINE_PRESETS, + type LineSegment, + type LineSegmentType, + type LinePresetKey, +} from "./lineSegments"; import { MotifEditor } from "./MotifEditor"; import { t } from "../i18n"; @@ -1235,26 +1244,35 @@ function LineStyleDetail({ onPatch({ zigzag: { ...cur, ...part } }); }; - // ── Strich (dash) ────────────────────────────────────────────────────────── - // „Vollinie" = `dash: null` (durchgezogen); „Strich" = editierbare Liste der - // Segmentlängen in mm (alternierend Strich/Lücke). Die Dicke gehört NICHT mehr - // hierher — sie wird per Element-Attribut aufgelöst (By-Object/By-Layer folgt). + // ── Modulares Segment-System (dash) ───────────────────────────────────────── + // Eine musterbasierte Linie ist eine geordnete, loopende Folge aus Segmenten + // (Strich / Punkt / Lücke). Die Folge wird aus `dash` abgeleitet und beim + // Editieren via `segmentsToDash` wieder nach `dash` geschrieben — `dash` bleibt + // die einzige Datenbasis. „Volllinie" = `dash: null`. Die Dicke gehört NICHT + // mehr hierher (sie wird per Element-Attribut aufgelöst; By-Object/By-Layer folgt). const dash = style.dash; const isSolid = dash === null; - const dashArr = dash ?? []; - const setStroke = (mode: "solid" | "dash") => - onPatch({ dash: mode === "solid" ? null : dashArr.length ? dashArr : [1, 2] }); - const setSeg = (i: number, v: number) => { - const next = dashArr.slice(); - next[i] = Math.max(0, v); - onPatch({ dash: next }); + const segments = dashToSegments(dash); + const preset = presetOfDash(dash); + const applyPreset = (p: LinePresetKey) => { + if (p === "custom") return; + onPatch({ dash: LINE_PRESETS[p] }); }; - const addSeg = () => - onPatch({ dash: [...dashArr, dashArr.length ? dashArr[dashArr.length - 1] : 1] }); - const removeSeg = (i: number) => { - const next = dashArr.filter((_, k) => k !== i); - onPatch({ dash: next.length ? next : null }); + const commitSegments = (next: LineSegment[]) => onPatch({ dash: segmentsToDash(next) }); + const setSegType = (i: number, type: LineSegmentType) => { + const next = segments.slice(); + const cur = next[i]; + next[i] = { type, length: type === "dot" ? 0 : cur.length > 0 ? cur.length : 2 }; + commitSegments(next); }; + const setSegLen = (i: number, v: number) => { + const next = segments.slice(); + next[i] = { ...next[i], length: Math.max(0, v) }; + commitSegments(next); + }; + const addSegOf = (type: LineSegmentType) => + commitSegments([...segments, { type, length: type === "dot" ? 0 : 2 }]); + const removeSeg = (i: number) => commitSegments(segments.filter((_, k) => k !== i)); return (
@@ -1319,13 +1337,16 @@ function LineStyleDetail({ ) : ( <> - - + + value={preset} + onChange={(p) => applyPreset(p as LinePresetKey)} options={[ - { value: "solid", label: t("resources.stroke.solid") }, - { value: "dash", label: t("resources.stroke.dash") }, + { value: "solid", label: t("resources.linePreset.solid") }, + { value: "dash", label: t("resources.linePreset.dash") }, + { value: "dot", label: t("resources.linePreset.dot") }, + { value: "dashDot", label: t("resources.linePreset.dashDot") }, ]} /> @@ -1334,26 +1355,29 @@ function LineStyleDetail({ label={t("resources.dash.segments")} hint={t("resources.dash.hint")} > -
- {dashArr.map((v, i) => ( - - - setSeg(i, x)} - step={0.5} - min={0} - /> +
+ {segments.map((seg, i) => ( +
+ setSegType(i, type)} + options={[ + { value: "dash", label: t("resources.seg.dash") }, + { value: "dot", label: t("resources.seg.dot") }, + { value: "gap", label: t("resources.seg.gap") }, + ]} + /> + + {seg.type === "dot" ? ( + 0 + ) : ( + setSegLen(i, x)} + step={0.5} + min={0} + /> + )} - +
))} - +
+ + + +
)} diff --git a/src/ui/hatchPreview.tsx b/src/ui/hatchPreview.tsx index 115c7dc..de39cc8 100644 --- a/src/ui/hatchPreview.tsx +++ b/src/ui/hatchPreview.tsx @@ -10,6 +10,7 @@ import { useId } from "react"; import type { HatchStyle, LineStyle } from "../model/types"; +import { dashHasDot } from "./lineSegments"; // ── Kleiner deterministischer PRNG (mulberry32) ──────────────────────────── // Für „random"-Striche: stabil pro Seed, damit die Vorschau bei gleichem @@ -330,8 +331,8 @@ export function LineSwatch({ strokeLinecap="round" /> ); - } else { - const dasharray = style.dash ? style.dash.map((d) => d * 4).join(" ") : undefined; + } else if (!style.dash || !style.dash.length) { + // Volllinie: durchgezogen, eine dunkle Linie. path = ( ); + } else { + // Muster-Linie (Strich/Punkt/Lücke, loopend): den ERSTEN Loop in Ink-Farbe + // (currentColor), danach 2 weitere Loops in Grau (var(--muted)) — so sieht man + // den fortgesetzten Loop. Punkte (0-Längen-Segmente) brauchen runde Kappen. + const PX_PER_MM = 4; + const dpx = style.dash.map((d) => d * PX_PER_MM); + const dasharray = dpx.join(" "); + const loopLen = dpx.reduce((a, b) => a + b, 0) || PX_PER_MM; + const cap = dashHasDot(style.dash) ? "round" : "butt"; + // Der graue Teil beginnt exakt an der Loop-Grenze (x0 + loopLen) → das Muster + // läuft phasengleich weiter (dash startet bei jedem Pfadanfang neu). + const darkEnd = Math.min(x1, x0 + loopLen); + const greyEnd = Math.min(x1, x0 + loopLen * 3); + path = ( + <> + {greyEnd > darkEnd && ( + + )} + + + ); } return ( diff --git a/src/ui/lineSegments.test.ts b/src/ui/lineSegments.test.ts new file mode 100644 index 0000000..1c786e8 --- /dev/null +++ b/src/ui/lineSegments.test.ts @@ -0,0 +1,111 @@ +// Modulares Linien-Segment-System: Abbildung Segmentliste ↔ dash-Array, +// Punkt-Erkennung (Round-Cap) und die Schnell-Presets. + +import { describe, it, expect } from "vitest"; +import { + segmentsToDash, + dashToSegments, + dashHasDot, + presetOfDash, + LINE_PRESETS, + type LineSegment, +} from "./lineSegments"; + +describe("segmentsToDash", () => { + it("bildet eine alternierende Strich/Lücke-Folge direkt ab", () => { + const segs: LineSegment[] = [ + { type: "dash", length: 2 }, + { type: "gap", length: 1 }, + { type: "dash", length: 3 }, + { type: "gap", length: 2 }, + ]; + expect(segmentsToDash(segs)).toEqual([2, 1, 3, 2]); + }); + + it("bildet einen Punkt als 0-Länge ab (Strich-Punkt)", () => { + const segs: LineSegment[] = [ + { type: "dash", length: 4 }, + { type: "gap", length: 2 }, + { type: "dot", length: 0 }, + { type: "gap", length: 2 }, + ]; + expect(segmentsToDash(segs)).toEqual([4, 2, 0, 2]); + }); + + it("fügt eine 0-Lücke zwischen zwei AN-Segmenten ein (Strich·Punkt)", () => { + const segs: LineSegment[] = [ + { type: "dash", length: 3 }, + { type: "dot", length: 0 }, + ]; + // 3 (Strich), 0 (eingefügte Lücke), 0 (Punkt), 0 (Schluss-Lücke, gerade Länge) + expect(segmentsToDash(segs)).toEqual([3, 0, 0, 0]); + }); + + it("fasst zwei Lücken in Folge zusammen", () => { + const segs: LineSegment[] = [ + { type: "dash", length: 2 }, + { type: "gap", length: 1 }, + { type: "gap", length: 2 }, + ]; + expect(segmentsToDash(segs)).toEqual([2, 3]); + }); + + it("endet stets auf einer AUS-Länge (gerade Array-Länge)", () => { + expect(segmentsToDash([{ type: "dash", length: 5 }])).toEqual([5, 0]); + }); + + it("leere Folge ⇒ null (Volllinie)", () => { + expect(segmentsToDash([])).toBeNull(); + }); +}); + +describe("dashToSegments", () => { + it("kehrt die Abbildung um (0 ⇒ Punkt)", () => { + expect(dashToSegments([4, 2, 0, 2])).toEqual([ + { type: "dash", length: 4 }, + { type: "gap", length: 2 }, + { type: "dot", length: 0 }, + { type: "gap", length: 2 }, + ]); + }); + + it("null ⇒ leere Folge", () => { + expect(dashToSegments(null)).toEqual([]); + }); + + it("round-trip: dash → segments → dash bleibt stabil", () => { + for (const d of [[2, 1, 3, 2], [4, 2, 0, 2], [0, 2], [3, 2]]) { + expect(segmentsToDash(dashToSegments(d))).toEqual(d); + } + }); +}); + +describe("dashHasDot", () => { + it("erkennt Punkte (0 im dash) für die runde Kappe", () => { + expect(dashHasDot([0, 2])).toBe(true); + expect(dashHasDot([4, 2, 0, 2])).toBe(true); + }); + it("keine Punkte ⇒ false (Butt-Kappe bleibt)", () => { + expect(dashHasDot([3, 2])).toBe(false); + expect(dashHasDot(null)).toBe(false); + expect(dashHasDot([])).toBe(false); + }); +}); + +describe("Presets", () => { + it("die vier Grundtypen sind erkennbar", () => { + expect(presetOfDash(LINE_PRESETS.solid)).toBe("solid"); + expect(presetOfDash(LINE_PRESETS.dash)).toBe("dash"); + expect(presetOfDash(LINE_PRESETS.dot)).toBe("dot"); + expect(presetOfDash(LINE_PRESETS.dashDot)).toBe("dashDot"); + }); + it("Punktlinie/Strich-Punkt enthalten einen Dot, Voll/Strich nicht", () => { + expect(dashHasDot(LINE_PRESETS.dot)).toBe(true); + expect(dashHasDot(LINE_PRESETS.dashDot)).toBe(true); + expect(dashHasDot(LINE_PRESETS.solid)).toBe(false); + expect(dashHasDot(LINE_PRESETS.dash)).toBe(false); + }); + it("Abweichungen ⇒ custom", () => { + expect(presetOfDash([1, 2, 3, 4])).toBe("custom"); + }); +}); diff --git a/src/ui/lineSegments.ts b/src/ui/lineSegments.ts new file mode 100644 index 0000000..4532423 --- /dev/null +++ b/src/ui/lineSegments.ts @@ -0,0 +1,107 @@ +// Modulares Linien-Segment-System — die Brücke zwischen dem Segment-Editor +// (ResourceManager) und der Datenbasis `LineStyle.dash` (alternierend AN/AUS in +// mm, loopend). Eine musterbasierte Linie ist eine geordnete, loopende Folge aus +// drei Segment-Arten: +// • "dash" — ein sichtbarer Strich (Länge in mm > 0), +// • "dot" — ein Punkt (AN-Segment der Länge 0; wird mit runder Strichkappe zum +// Dot, siehe dashHasDot + `stroke-linecap: round` in den Renderern), +// • "gap" — eine Lücke (AUS-Länge in mm). +// Die letzte Lücke der Folge ist die „Schluss-Lücke" (Ende des Loops bis zum +// Start des nächsten). Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +/** Eine Segment-Art des Linien-Musters. */ +export type LineSegmentType = "dash" | "dot" | "gap"; + +/** Ein Segment der Muster-Folge (Länge in mm; bei „dot" ignoriert/0). */ +export interface LineSegment { + type: LineSegmentType; + /** Länge in mm (Papier). Bei „dot" stets 0. */ + length: number; +} + +/** + * Bildet eine geordnete Segment-Folge auf ein `dash`-Array ab + * (`[on, off, on, off, …]` in mm; ein `on`-Wert von 0 ⇒ Punkt). Da SVG-Dash + * strikt AN/AUS alterniert, werden 0-Längen eingefügt bzw. gleichartige + * Nachbarn zusammengefasst, um die Alternation zu wahren: + * • zwei AN-Segmente in Folge (z. B. Strich·Punkt) ⇒ 0-Lücke dazwischen, + * • zwei Lücken in Folge ⇒ zur vorigen Lücke addiert. + * Das Muster endet stets auf einer AUS-Länge (gerade Array-Länge), damit es + * sauber loopt. Leere Folge ⇒ `null` (Volllinie). + */ +export function segmentsToDash(segments: LineSegment[]): number[] | null { + const dash: number[] = []; + for (const seg of segments) { + const on = seg.type !== "gap"; + const value = seg.type === "dot" ? 0 : Math.max(0, seg.length); + const nextSlotIsOn = dash.length % 2 === 0; + if (on) { + if (!nextSlotIsOn) dash.push(0); // 0-Lücke, um die AN/AUS-Alternation zu wahren + dash.push(value); + } else { + if (nextSlotIsOn) { + if (dash.length > 0) { + // zwei Lücken in Folge → in die vorherige Lücke einrechnen + dash[dash.length - 1] += value; + continue; + } + dash.push(0); // führende Lücke: 0-Strich davor + } + dash.push(value); + } + } + // Muster muss mit einer AUS-Länge enden (gerade Länge), damit es sauber loopt. + if (dash.length % 2 === 1) dash.push(0); + return dash.length ? dash : null; +} + +/** + * Kehrt `segmentsToDash` um: ein `dash`-Array (`[on, off, …]`) wird zur + * Segment-Folge. Gerade Indizes sind AN (0 ⇒ Punkt, sonst Strich), ungerade + * Indizes sind Lücken. `null`/leer ⇒ leere Folge (Volllinie). + */ +export function dashToSegments(dash: number[] | null | undefined): LineSegment[] { + if (!dash) return []; + return dash.map((v, i) => + i % 2 === 0 + ? { type: v === 0 ? "dot" : "dash", length: v } + : { type: "gap", length: v }, + ); +} + +/** + * Enthält das Strichmuster einen Punkt (ein AN-Segment der Länge 0)? Solche + * Linien müssen mit runder Strichkappe (`stroke-linecap: round`) gezeichnet + * werden, damit die 0-Längen-Segmente als Dots sichtbar werden. + */ +export function dashHasDot(dash: number[] | null | undefined): boolean { + return !!dash && dash.some((v) => v === 0); +} + +/** Schnell-Presets für die drei Grundtypen (+ Strich-Punkt), Längen in mm. */ +export const LINE_PRESETS = { + /** Volllinie — durchgezogen. */ + solid: null as number[] | null, + /** Strichlinie. */ + dash: [3, 2] as number[] | null, + /** Punktlinie (Dots). */ + dot: [0, 2] as number[] | null, + /** Strich-Punkt-Linie. */ + dashDot: [4, 2, 0, 2] as number[] | null, +} as const; + +export type LinePresetKey = keyof typeof LINE_PRESETS | "custom"; + +/** Erkennt, welchem Preset ein `dash`-Array entspricht (sonst „custom"). */ +export function presetOfDash(dash: number[] | null | undefined): LinePresetKey { + const eq = (a: number[] | null, b: number[] | null): boolean => { + if (a === null || b === null) return a === b; + return a.length === b.length && a.every((v, i) => v === b[i]); + }; + const d = dash ?? null; + if (eq(d, LINE_PRESETS.solid)) return "solid"; + if (eq(d, LINE_PRESETS.dash)) return "dash"; + if (eq(d, LINE_PRESETS.dot)) return "dot"; + if (eq(d, LINE_PRESETS.dashDot)) return "dashDot"; + return "custom"; +}