From e20d74b281f0d381c07a4413e72e1260fa708cc8 Mon Sep 17 00:00:00 2001
From: syalr2
Date: Tue, 10 Nov 2026 09:54:22 -0500
Subject: [PATCH 1/3] Model yoke stitch targets, schedules and shared drawing
geometry
---
lib/yoke-layout.ts | 67 +++++++++++++
lib/yoke-plan.test.ts | 214 ++++++++++++++++++++++++++++++++++++++++++
lib/yoke-plan.ts | 197 ++++++++++++++++++++++++++++++++++++++
lib/yoke-project.ts | 100 ++++++++++++++++++++
4 files changed, 578 insertions(+)
create mode 100644 lib/yoke-layout.ts
create mode 100644 lib/yoke-plan.test.ts
create mode 100644 lib/yoke-plan.ts
create mode 100644 lib/yoke-project.ts
diff --git a/lib/yoke-layout.ts b/lib/yoke-layout.ts
new file mode 100644
index 0000000..825f608
--- /dev/null
+++ b/lib/yoke-layout.ts
@@ -0,0 +1,67 @@
+export type YokeView = "whole" | "repeat" | "chart";
+export const YOKE_CANVAS = { width: 1000, height: 800 };
+const TAU = Math.PI * 2;
+const margin = 40;
+
+export function yokeGeometry(
+ counts: number[],
+ repeats: number,
+ view: YokeView,
+ aspect: number,
+) {
+ const shownCounts = counts.map((count) =>
+ view === "whole" ? count : count / repeats,
+ );
+ const largest = Math.max(...shownCounts);
+ const rowHeight = (YOKE_CANVAS.height - margin * 2) / counts.length;
+ const cellWidth = Math.min(
+ rowHeight * aspect,
+ (YOKE_CANVAS.width - margin * 2) / largest,
+ );
+ const arc = view === "whole" ? TAU : TAU / repeats;
+ const inner = view === "whole" ? 95 : 80;
+ const outer = view === "whole" ? 355 : 690;
+ return {
+ shownCounts,
+ cellWidth,
+ rowHeight,
+ arc,
+ inner,
+ outer,
+ radialHeight: (outer - inner) / counts.length,
+ cx: 500,
+ cy: view === "whole" ? 400 : 20,
+ startAngle: view === "whole" ? -Math.PI / 2 : Math.PI / 2 - arc / 2,
+ };
+}
+
+/** Shared geometry for drawing AND hit testing; CSS scaling is handled by callers. */
+export function yokeCellAt(
+ counts: number[],
+ repeats: number,
+ view: YokeView,
+ aspect: number,
+ x: number,
+ y: number,
+) {
+ const g = yokeGeometry(counts, repeats, view, aspect);
+ if (view === "chart") {
+ const row = Math.floor((YOKE_CANVAS.height - margin - y) / g.rowHeight);
+ if (row < 0 || row >= counts.length) return null;
+ const left = (YOKE_CANVAS.width - g.shownCounts[row] * g.cellWidth) / 2;
+ const column = Math.floor((x - left) / g.cellWidth);
+ return column >= 0 && column < g.shownCounts[row] ? { row, column } : null;
+ }
+ const dx = x - g.cx,
+ dy = y - g.cy;
+ const radius = Math.hypot(dx, dy);
+ const row = Math.floor((radius - g.inner) / g.radialHeight);
+ if (row < 0 || row >= counts.length) return null;
+ const angle = (((Math.atan2(dy, dx) - g.startAngle) % TAU) + TAU) % TAU;
+ if (angle >= g.arc) return null;
+ const column = Math.min(
+ g.shownCounts[row] - 1,
+ Math.floor((angle / g.arc) * g.shownCounts[row]),
+ );
+ return { row, column };
+}
diff --git a/lib/yoke-plan.test.ts b/lib/yoke-plan.test.ts
new file mode 100644
index 0000000..294e8d3
--- /dev/null
+++ b/lib/yoke-plan.test.ts
@@ -0,0 +1,214 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ calculateYokeTargets,
+ defaultYokeMeasurements as defaults,
+ convertYokeUnits,
+ suggestYokeSchedule,
+ yokeRoundCounts,
+} from "./yoke-plan";
+import {
+ createYokeProject,
+ paintYoke,
+ yokeProjectSchema,
+} from "./yoke-project";
+import { yokeGeometry, yokeCellAt, type YokeView } from "./yoke-layout";
+
+const plan = calculateYokeTargets(defaults);
+const events = suggestYokeSchedule(plan, defaults.repeats);
+test("underarms are counted twice in the body and once in each sleeve", () => {
+ const target = calculateYokeTargets({
+ ...defaults,
+ repeats: 2,
+ chest: 100,
+ bodyEase: 10,
+ });
+ assert.equal(target.body, 220);
+ assert.equal(target.sleeve, 72);
+ assert.equal(target.underarm, 8);
+ assert.equal(target.end, 332);
+ assert.equal(target.bodyFromYoke, 204);
+ assert.equal(target.sleeveFromYoke, 64);
+ assert.equal(target.bodyFromYoke + 2 * target.sleeveFromYoke, target.end);
+ assert.equal(target.bodyFromYoke + 2 * target.underarm, target.body);
+ assert.equal(target.sleeveFromYoke + target.underarm, target.sleeve);
+});
+test("repeat rounding exposes its effect on actual measurements", () => {
+ assert.equal(plan.neck, 84);
+ assert.equal(plan.neckAdjustment, -4);
+ assert.equal(plan.actualNeck, 42);
+ assert.equal(plan.end % defaults.repeats, 0);
+ assert.equal(
+ plan.actualBody,
+ (plan.end - 2 * plan.sleeve + 4 * plan.underarm) / 2,
+ );
+});
+test("unit conversion preserves stitch counts, full rounds and short-row allowance", () => {
+ const inches = calculateYokeTargets(convertYokeUnits(defaults, "in"));
+ for (const key of [
+ "neck",
+ "end",
+ "body",
+ "sleeve",
+ "underarm",
+ "rounds",
+ "shortRowPairs",
+ ] as const)
+ assert.equal(inches[key], plan[key]);
+ const roundtrip = convertYokeUnits(convertYokeUnits(defaults, "in"), "cm");
+ assert.ok(Math.abs(roundtrip.neck - defaults.neck) < 1e-10);
+});
+test("short rows add local height, never full rounds or stitches", () => {
+ const without = calculateYokeTargets({ ...defaults, backRise: 0 });
+ assert.equal(without.rounds, plan.rounds);
+ assert.equal(without.end, plan.end);
+ assert.equal(without.shortRowPairs, 0);
+ assert.equal(plan.shortRowPairs, 3);
+ assert.equal(plan.actualBackRise, 6 / 2.8);
+});
+test("invalid geometry and impossible measurements are rejected before allocation", () => {
+ for (const update of [
+ { gaugeStitches: 0 },
+ { gaugeRows: NaN },
+ { depth: Infinity },
+ { underarm: 40 },
+ { repeats: 2.5 },
+ { depth: 100 },
+ { neck: 200 },
+ { chest: -1 },
+ ]) {
+ assert.throws(() => calculateYokeTargets({ ...defaults, ...update }));
+ }
+});
+test("suggested schedules are internally consistent across gauges, counts and repeat choices", () => {
+ let checked = 0;
+ for (const gaugeStitches of [12, 20, 28])
+ for (const gaugeRows of [18, 28, 36])
+ for (const repeats of [4, 8, 12, 20])
+ for (const chest of [75, 100, 140]) {
+ const measurements = {
+ ...defaults,
+ gaugeStitches,
+ gaugeRows,
+ repeats,
+ chest,
+ };
+ const target = calculateYokeTargets(measurements);
+ const counts = yokeRoundCounts(
+ target,
+ repeats,
+ suggestYokeSchedule(target, repeats),
+ );
+ assert.equal(counts.length, target.rounds);
+ assert.equal(counts[0], target.neck);
+ assert.equal(counts.at(-1), target.end);
+ assert.ok(
+ counts.every(
+ (count, index) =>
+ count % repeats === 0 && (!index || count >= counts[index - 1]),
+ ),
+ );
+ checked++;
+ }
+ assert.equal(checked, 108);
+});
+test("short five-round schedules retain unique increase rounds and viable counts", () => {
+ const target = calculateYokeTargets({ ...defaults, depth: 1.8 });
+ assert.equal(target.rounds, 5);
+ assert.doesNotThrow(() =>
+ yokeRoundCounts(
+ target,
+ defaults.repeats,
+ suggestYokeSchedule(target, defaults.repeats),
+ ),
+ );
+});
+test("schedule validation rejects duplicate rounds, nonmultiples, missing targets and oversized jumps", () => {
+ for (const schedule of [
+ [...events, events[0]],
+ [{ round: 2, count: plan.end - 1 }],
+ [],
+ [{ round: 2, count: plan.end }],
+ [{ round: 151, count: plan.end }],
+ ]) {
+ assert.throws(() => yokeRoundCounts(plan, defaults.repeats, schedule));
+ }
+});
+test("painting repeats preserves other rounds and supports independent variations", () => {
+ const original = createYokeProject(defaults, events);
+ const copy = JSON.stringify(original);
+ const next = paintYoke(original, 0, 2, 1, true);
+ const width = plan.neck / defaults.repeats;
+ assert.equal(
+ next.cells[0].filter((color) => color === 1).length,
+ defaults.repeats,
+ );
+ assert.ok(
+ next.cells[0].every(
+ (color, column) => color === (column % width === 2 ? 1 : 0),
+ ),
+ );
+ assert.equal(next.cells[1], original.cells[1]);
+ assert.equal(JSON.stringify(original), copy);
+ const one = paintYoke(original, 0, 2, 1, false);
+ assert.equal(one.cells[0].filter((color) => color === 1).length, 1);
+ assert.equal(paintYoke(one, -1, 0, 1, false), one);
+ assert.equal(paintYoke(one, 0, 0, 99, false), one);
+});
+test("project validation rejects wrong cell counts and missing palette colours", () => {
+ const project = createYokeProject(defaults, events);
+ assert.equal(yokeProjectSchema.safeParse(project).success, true);
+ assert.equal(
+ yokeProjectSchema.safeParse({ ...project, version: 2 }).success,
+ false,
+ );
+ assert.equal(
+ yokeProjectSchema.safeParse({ ...project, cells: [] }).success,
+ false,
+ );
+ assert.equal(
+ yokeProjectSchema.safeParse({
+ ...project,
+ palette: ["#000000", "#ffffff"],
+ cells: project.cells.map((row) => row.map(() => 2)),
+ }).success,
+ false,
+ );
+});
+test("all drawing views hit exactly the cells they render, including outside boundaries", () => {
+ const counts = yokeRoundCounts(plan, defaults.repeats, events);
+ for (const view of ["whole", "repeat", "chart"] as YokeView[]) {
+ const g = yokeGeometry(counts, defaults.repeats, view, 1.4);
+ assert.equal(
+ g.arc,
+ view === "whole" ? 2 * Math.PI : (2 * Math.PI) / defaults.repeats,
+ );
+ for (const row of [0, 10, counts.length - 1])
+ for (const column of [
+ 0,
+ Math.floor(g.shownCounts[row] / 2),
+ g.shownCounts[row] - 1,
+ ]) {
+ const angle =
+ g.startAngle + ((column + 0.5) / g.shownCounts[row]) * g.arc;
+ const radius = g.inner + (row + 0.5) * g.radialHeight;
+ const x =
+ view === "chart"
+ ? (1000 - g.shownCounts[row] * g.cellWidth) / 2 +
+ (column + 0.5) * g.cellWidth
+ : g.cx + Math.cos(angle) * radius;
+ const y =
+ view === "chart"
+ ? 760 - (row + 0.5) * g.rowHeight
+ : g.cy + Math.sin(angle) * radius;
+ assert.deepEqual(
+ yokeCellAt(counts, defaults.repeats, view, 1.4, x, y),
+ { row, column },
+ );
+ }
+ assert.equal(
+ yokeCellAt(counts, defaults.repeats, view, 1.4, -50, -50),
+ null,
+ );
+ }
+});
diff --git a/lib/yoke-plan.ts b/lib/yoke-plan.ts
new file mode 100644
index 0000000..5b201b5
--- /dev/null
+++ b/lib/yoke-plan.ts
@@ -0,0 +1,197 @@
+import { z } from "zod";
+
+const length = z.number().finite().positive().max(400);
+export const yokeMeasurementsSchema = z.object({
+ unit: z.enum(["cm", "in"]),
+ gaugeStitches: length,
+ gaugeRows: length,
+ gaugeMeasure: length,
+ neck: length,
+ chest: length,
+ bodyEase: z.number().finite().min(-20).max(60),
+ upperArm: length,
+ sleeveEase: z.number().finite().min(-10).max(30),
+ underarm: length,
+ depth: length,
+ backRise: z.number().finite().min(0).max(15),
+ repeats: z.number().int().min(2).max(40),
+});
+export type YokeMeasurements = z.infer;
+export const defaultYokeMeasurements: YokeMeasurements = {
+ unit: "cm",
+ gaugeStitches: 20,
+ gaugeRows: 28,
+ gaugeMeasure: 10,
+ neck: 44,
+ chest: 96,
+ bodyEase: 10,
+ upperArm: 32,
+ sleeveEase: 4,
+ underarm: 4,
+ depth: 22,
+ backRise: 2,
+ repeats: 12,
+};
+export const increaseEventSchema = z.object({
+ round: z.number().int().min(2).max(150),
+ count: z.number().int().min(1).max(1200),
+});
+export type IncreaseEvent = z.infer;
+export type YokePlan = ReturnType;
+
+/** Finished body includes TWO underarms; each finished sleeve includes ONE. */
+export function calculateYokeTargets(value: YokeMeasurements) {
+ const input = yokeMeasurementsSchema.parse(value);
+ const stitchesPerUnit = input.gaugeStitches / input.gaugeMeasure;
+ const rowsPerUnit = input.gaugeRows / input.gaugeMeasure;
+ const body = Math.round((input.chest + input.bodyEase) * stitchesPerUnit);
+ const sleeve = Math.round(
+ (input.upperArm + input.sleeveEase) * stitchesPerUnit,
+ );
+ const underarm = Math.round(input.underarm * stitchesPerUnit);
+ const requestedNeck = Math.round(input.neck * stitchesPerUnit);
+ const requestedEnd = body + 2 * sleeve - 4 * underarm;
+ const multiple = (count: number) =>
+ Math.round(count / input.repeats) * input.repeats;
+ const neck = multiple(requestedNeck);
+ const end = multiple(requestedEnd);
+ const rounds = Math.round(input.depth * rowsPerUnit);
+ const shortRowPairs = Math.round((input.backRise * rowsPerUnit) / 2);
+ const actualBody = end - 2 * (sleeve - underarm) + 2 * underarm;
+ if (body <= 2 * underarm || sleeve <= underarm || underarm < 1)
+ throw new Error(
+ "The underarm allowance must leave stitches for the body and both sleeves.",
+ );
+ if (neck < input.repeats * 2 || end <= neck)
+ throw new Error(
+ "Choose a lower neck count, a larger lower yoke, or fewer repeats.",
+ );
+ if (end > 1200 || end / input.repeats > 200 || rounds < 5 || rounds > 150)
+ throw new Error(
+ "This first version supports 5–150 full rounds, up to 1,200 total stitches and 200 stitches per repeat.",
+ );
+ if (Math.ceil(Math.log2(end / neck)) > rounds - 1)
+ throw new Error(
+ "There are too few rounds for that amount of increase. Increase the yoke length or revise the target counts.",
+ );
+ return {
+ neck,
+ end,
+ rounds,
+ body: actualBody,
+ sleeve,
+ underarm,
+ bodyFromYoke: actualBody - 2 * underarm,
+ sleeveFromYoke: sleeve - underarm,
+ actualNeck: neck / stitchesPerUnit,
+ actualBody: actualBody / stitchesPerUnit,
+ actualSleeve: sleeve / stitchesPerUnit,
+ actualDepth: rounds / rowsPerUnit,
+ neckAdjustment: neck - requestedNeck,
+ bodyAdjustment: actualBody - body,
+ shortRowPairs,
+ actualBackRise: (shortRowPairs * 2) / rowsPerUnit,
+ };
+}
+
+/** An editable scaffold, NOT a validated garment-fitting prescription. */
+export function suggestYokeSchedule(
+ plan: YokePlan,
+ repeats: number,
+): IncreaseEvent[] {
+ const start = plan.neck / repeats,
+ end = plan.end / repeats;
+ const stages = Math.min(
+ plan.rounds - 1,
+ end - start,
+ Math.max(4, Math.ceil(Math.log2(end / start))),
+ );
+ let previousRound = 1,
+ previousCount = start;
+ return Array.from({ length: stages }, (_, index) => {
+ const remaining = stages - index - 1;
+ const fraction =
+ stages === 4
+ ? [0.15, 0.32, 0.5, 0.72][index]
+ : (index + 1) / (stages + 1);
+ const round = Math.min(
+ plan.rounds - remaining,
+ Math.max(previousRound + 1, Math.round(1 + (plan.rounds - 1) * fraction)),
+ );
+ const ideal = Math.round(start * (end / start) ** ((index + 1) / stages));
+ const count = Math.min(
+ previousCount * 2,
+ end - remaining,
+ Math.max(previousCount + 1, ideal, Math.ceil(end / 2 ** remaining)),
+ );
+ previousRound = round;
+ previousCount = count;
+ return { round, count: count * repeats };
+ });
+}
+
+export function yokeRoundCounts(
+ plan: YokePlan,
+ repeats: number,
+ events: IncreaseEvent[],
+) {
+ let count = plan.neck;
+ let previousRound = 1;
+ for (const candidate of events) {
+ const event = increaseEventSchema.parse(candidate);
+ if (event.round <= previousRound || event.round > plan.rounds)
+ throw new Error(
+ "Increase rounds must be in ascending order, within the yoke.",
+ );
+ if (
+ event.count <= count ||
+ event.count % repeats !== 0 ||
+ event.count > plan.end
+ )
+ throw new Error(
+ "Each count must increase by whole repeats without exceeding the lower-yoke target.",
+ );
+ if (event.count > 2 * count)
+ throw new Error(
+ "Split this increase across more rounds: a round cannot more than double its stitch count.",
+ );
+ previousRound = event.round;
+ count = event.count;
+ }
+ if (count !== plan.end)
+ throw new Error(`The last increase must reach ${plan.end} stitches.`);
+ let eventIndex = 0;
+ count = plan.neck;
+ return Array.from({ length: plan.rounds }, (_, index) => {
+ if (events[eventIndex]?.round === index + 1)
+ count = events[eventIndex++].count;
+ return count;
+ });
+}
+
+export function convertYokeUnits(
+ input: YokeMeasurements,
+ unit: "cm" | "in",
+): YokeMeasurements {
+ if (unit === input.unit) return input;
+ const factor = unit === "in" ? 1 / 2.54 : 2.54;
+ return {
+ ...input,
+ unit,
+ ...Object.fromEntries(
+ (
+ [
+ "gaugeMeasure",
+ "neck",
+ "chest",
+ "bodyEase",
+ "upperArm",
+ "sleeveEase",
+ "underarm",
+ "depth",
+ "backRise",
+ ] as const
+ ).map((key) => [key, input[key] * factor]),
+ ),
+ };
+}
diff --git a/lib/yoke-project.ts b/lib/yoke-project.ts
new file mode 100644
index 0000000..e310798
--- /dev/null
+++ b/lib/yoke-project.ts
@@ -0,0 +1,100 @@
+import { z } from "zod";
+import {
+ calculateYokeTargets,
+ increaseEventSchema,
+ yokeMeasurementsSchema,
+ yokeRoundCounts,
+ type YokeMeasurements,
+ type IncreaseEvent,
+} from "./yoke-plan";
+
+const paletteSchema = z
+ .array(z.string().regex(/^#[0-9a-f]{6}$/i))
+ .min(2)
+ .max(8);
+export const yokeProjectSchema = z
+ .object({
+ format: z.literal("knitplot-yoke"),
+ version: z.literal(1),
+ measurements: yokeMeasurementsSchema,
+ events: z.array(increaseEventSchema).max(149),
+ palette: paletteSchema,
+ cells: z.array(z.array(z.number().int().min(0).max(7)).max(1200)).max(150),
+ })
+ .superRefine((project, ctx) => {
+ try {
+ const counts = yokeRoundCounts(
+ calculateYokeTargets(project.measurements),
+ project.measurements.repeats,
+ project.events,
+ );
+ if (
+ project.cells.length !== counts.length ||
+ project.cells.some(
+ (row, index) =>
+ row.length !== counts[index] ||
+ row.some((color) => color >= project.palette.length),
+ )
+ )
+ throw new Error("Invalid yoke stitch data.");
+ } catch (error) {
+ ctx.addIssue({
+ code: "custom",
+ message: error instanceof Error ? error.message : "Invalid yoke plan.",
+ });
+ }
+ });
+export type YokeProject = z.infer;
+
+export function createYokeProject(
+ measurements: YokeMeasurements,
+ events: IncreaseEvent[],
+): YokeProject {
+ const counts = yokeRoundCounts(
+ calculateYokeTargets(measurements),
+ measurements.repeats,
+ events,
+ );
+ return {
+ format: "knitplot-yoke",
+ version: 1,
+ measurements: { ...measurements },
+ events: events.map((event) => ({ ...event })),
+ palette: ["#f2eadf", "#334c44", "#c86f52"],
+ cells: counts.map((count) => Array(count).fill(0)),
+ };
+}
+
+export function paintYoke(
+ project: YokeProject,
+ row: number,
+ column: number,
+ color: number,
+ allRepeats: boolean,
+): YokeProject {
+ if (
+ !Number.isInteger(row) ||
+ !Number.isInteger(column) ||
+ !Number.isInteger(color) ||
+ !project.cells[row] ||
+ column < 0 ||
+ column >= project.cells[row].length ||
+ color < 0 ||
+ color >= project.palette.length
+ )
+ return project;
+ const width = project.cells[row].length / project.measurements.repeats;
+ const cells = project.cells.slice();
+ let changed = false;
+ cells[row] = project.cells[row].map((previous, index) => {
+ if (
+ (allRepeats ? index % width === column % width : index === column) &&
+ previous !== color
+ ) {
+ changed = true;
+ return color;
+ }
+ return previous;
+ });
+ return changed ? { ...project, cells } : project;
+}
From 5a259514ab737190b36cb8016af7d8f3acb1e213 Mon Sep 17 00:00:00 2001
From: syalr2
Date: Tue, 10 Nov 2026 09:54:22 -0500
Subject: [PATCH 2/3] Add development yoke workspace with linked drawing views
and safe drafts
---
app/yoke-designer/page.dev.tsx | 8 +
app/yoke-designer/yoke.css | 156 ++++++++++++++++++
components/use-yoke-draft.ts | 78 +++++++++
components/yoke-canvas.tsx | 149 +++++++++++++++++
components/yoke-designer.tsx | 286 ++++++++++++++++++++++++++++++++
components/yoke-planner.tsx | 291 +++++++++++++++++++++++++++++++++
docs/yoke-designer.md | 65 ++++++++
e2e/editor.spec.ts | 1 +
e2e/harness/app/yoke/page.tsx | 6 +
e2e/yoke.spec.ts | 192 ++++++++++++++++++++++
playwright.config.ts | 2 +-
11 files changed, 1233 insertions(+), 1 deletion(-)
create mode 100644 app/yoke-designer/page.dev.tsx
create mode 100644 app/yoke-designer/yoke.css
create mode 100644 components/use-yoke-draft.ts
create mode 100644 components/yoke-canvas.tsx
create mode 100644 components/yoke-designer.tsx
create mode 100644 components/yoke-planner.tsx
create mode 100644 docs/yoke-designer.md
create mode 100644 e2e/harness/app/yoke/page.tsx
create mode 100644 e2e/yoke.spec.ts
diff --git a/app/yoke-designer/page.dev.tsx b/app/yoke-designer/page.dev.tsx
new file mode 100644
index 0000000..1ffdd35
--- /dev/null
+++ b/app/yoke-designer/page.dev.tsx
@@ -0,0 +1,8 @@
+import { notFound } from "next/navigation";
+import { YokeDesigner } from "@/components/yoke-designer";
+import "./yoke.css";
+
+export default function YokeDesignerPage() {
+ if (process.env.NODE_ENV !== "development") notFound();
+ return ;
+}
diff --git a/app/yoke-designer/yoke.css b/app/yoke-designer/yoke.css
new file mode 100644
index 0000000..20875bb
--- /dev/null
+++ b/app/yoke-designer/yoke.css
@@ -0,0 +1,156 @@
+.yoke-designer {
+ max-width: 1600px;
+ margin: auto;
+ padding: 24px;
+}
+.yoke-header,
+.yoke-header nav,
+.yoke-toolbar,
+.yoke-view-tabs,
+.yoke-summary {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+.yoke-header {
+ justify-content: space-between;
+ margin-bottom: 24px;
+}
+.yoke-header h1 {
+ font-size: 25px;
+ margin: 0;
+}
+.yoke-header h1 span {
+ color: var(--accent-deep);
+}
+.yoke-header p {
+ margin: 5px 0;
+ font-size: 12px;
+}
+.yoke-workspace {
+ display: grid;
+ grid-template-columns: 340px minmax(0, 1fr);
+ gap: 20px;
+ align-items: start;
+}
+.yoke-panel {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ padding: 20px;
+ min-width: 0;
+}
+.yoke-panel h2 {
+ font-size: 22px;
+ margin: 0 0 8px;
+}
+.yoke-panel h3 {
+ font-size: 17px;
+ margin: 20px 0 8px;
+}
+.yoke-panel p,
+.yoke-panel li {
+ font-size: 13px;
+ line-height: 1.5;
+}
+.yoke-panel .yoke-hint,
+.yoke-panel small {
+ color: #686658;
+ font-size: 12px;
+}
+.yoke-fields {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ margin: 16px 0;
+}
+.yoke-panel .yoke-fields label {
+ display: flex;
+ flex-direction: column;
+}
+.yoke-panel label {
+ display: grid;
+ gap: 6px;
+ font-size: 12px;
+ min-width: 0;
+}
+.yoke-panel input:not([type="checkbox"]):not([type="color"]),
+.yoke-panel select {
+ width: 100%;
+ min-width: 0;
+ padding: 7px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: white;
+ font: inherit;
+}
+.yoke-panel small {
+ line-height: 1.4;
+}
+.yoke-schedule > div {
+ display: grid;
+ grid-template-columns: 1fr 1fr 30px;
+ gap: 8px;
+ align-items: end;
+ margin-bottom: 10px;
+}
+.yoke-summary {
+ justify-content: space-between;
+ font-size: 13px;
+ margin-bottom: 16px;
+}
+.yoke-toolbar {
+ margin-top: 16px;
+}
+.yoke-toolbar label {
+ display: flex;
+ align-items: center;
+}
+.yoke-colour {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+}
+.yoke-colour button {
+ border: 2px solid transparent;
+ border-radius: 50%;
+ width: 28px;
+ height: 28px;
+}
+.yoke-colour button[aria-pressed="true"] {
+ outline: 2px solid var(--accent-deep);
+ outline-offset: 2px;
+}
+.yoke-colour input {
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: 0;
+}
+.yoke-canvas-scroll {
+ width: 100%;
+ overflow: auto;
+ background: white;
+ border-radius: 8px;
+}
+.yoke-canvas-scroll canvas {
+ display: block;
+ max-width: none;
+ height: auto;
+ touch-action: none;
+ user-select: none;
+ cursor: crosshair;
+}
+.yoke-drawing details {
+ margin-top: 14px;
+ font-size: 13px;
+}
+@media (max-width: 900px) {
+ .yoke-workspace {
+ grid-template-columns: 1fr;
+ }
+ .yoke-designer {
+ padding: 12px;
+ }
+}
diff --git a/components/use-yoke-draft.ts b/components/use-yoke-draft.ts
new file mode 100644
index 0000000..98a273e
--- /dev/null
+++ b/components/use-yoke-draft.ts
@@ -0,0 +1,78 @@
+"use client";
+import {
+ startTransition,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import { yokeProjectSchema, type YokeProject } from "@/lib/yoke-project";
+
+const KEY = "knitplot-yoke-draft-v1";
+/** Isolated from regular charts; refuses to overwrite a corrupt or concurrently edited draft. */
+export function useYokeDraft(initial: YokeProject) {
+ const [project, setProject] = useState(initial);
+ const [ready, setReady] = useState(false);
+ const [error, setError] = useState("");
+ const stored = useRef(null);
+ const latest = useRef(project);
+ useLayoutEffect(() => {
+ latest.current = project;
+ }, [project]);
+ useEffect(() => {
+ try {
+ const text = localStorage.getItem(KEY);
+ const parsed = text ? yokeProjectSchema.parse(JSON.parse(text)) : initial;
+ stored.current = text;
+ startTransition(() => {
+ setProject(parsed);
+ setReady(true);
+ });
+ } catch {
+ startTransition(() => {
+ setError(
+ "The saved yoke draft could not be read. It has been left untouched. Download your work before leaving.",
+ );
+ setReady(true);
+ });
+ }
+ }, [initial]);
+ useEffect(() => {
+ if (!ready) return;
+ function save() {
+ try {
+ if (localStorage.getItem(KEY) !== stored.current) throw new Error();
+ const text = JSON.stringify(latest.current);
+ if (text !== stored.current) localStorage.setItem(KEY, text);
+ stored.current = text;
+ setError("");
+ return true;
+ } catch {
+ setError(
+ "This yoke draft could not be saved, or another window changed it. Download your work before leaving.",
+ );
+ return false;
+ }
+ }
+ const timeout = setTimeout(save, 200);
+ const beforeUnload = (event: BeforeUnloadEvent) => {
+ if (!save()) {
+ event.preventDefault();
+ event.returnValue = "";
+ }
+ };
+ const hide = () => {
+ if (document.visibilityState === "hidden") save();
+ };
+ window.addEventListener("beforeunload", beforeUnload);
+ window.addEventListener("pagehide", save);
+ document.addEventListener("visibilitychange", hide);
+ return () => {
+ clearTimeout(timeout);
+ window.removeEventListener("beforeunload", beforeUnload);
+ window.removeEventListener("pagehide", save);
+ document.removeEventListener("visibilitychange", hide);
+ };
+ }, [project, ready]);
+ return { project, setProject, ready, error };
+}
diff --git a/components/yoke-canvas.tsx b/components/yoke-canvas.tsx
new file mode 100644
index 0000000..9ab7a3b
--- /dev/null
+++ b/components/yoke-canvas.tsx
@@ -0,0 +1,149 @@
+"use client";
+import { useEffect, useRef, type PointerEvent } from "react";
+import type { YokeProject } from "@/lib/yoke-project";
+import {
+ YOKE_CANVAS,
+ yokeGeometry,
+ yokeCellAt,
+ type YokeView,
+} from "@/lib/yoke-layout";
+
+type Props = {
+ project: YokeProject;
+ view: YokeView;
+ zoom: number;
+ onPaint: (row: number, column: number) => void;
+ onStrokeStart: () => void;
+};
+export function YokeCanvas({
+ project,
+ view,
+ zoom,
+ onPaint,
+ onStrokeStart,
+}: Props) {
+ const ref = useRef(null);
+ const drawing = useRef(false);
+ const counts = project.cells.map((row) => row.length);
+ const aspect =
+ project.measurements.gaugeRows / project.measurements.gaugeStitches;
+ useEffect(() => {
+ const canvas = ref.current;
+ const context = canvas?.getContext("2d");
+ if (!canvas || !context) return;
+ context.clearRect(0, 0, canvas.width, canvas.height);
+ const g = yokeGeometry(
+ project.cells.map((row) => row.length),
+ project.measurements.repeats,
+ view,
+ aspect,
+ );
+ const increaseRows = new Set(
+ project.events.map((event) => event.round - 1),
+ );
+ project.cells.forEach((row, r) => {
+ const count = g.shownCounts[r];
+ const bottom = YOKE_CANVAS.height - 40 - r * g.rowHeight;
+ for (let c = 0; c < count; c++) {
+ context.fillStyle = project.palette[row[c]];
+ context.beginPath();
+ if (view === "chart") {
+ context.rect(
+ (canvas.width - count * g.cellWidth) / 2 + c * g.cellWidth,
+ bottom - g.rowHeight,
+ g.cellWidth,
+ g.rowHeight,
+ );
+ } else {
+ const a = g.startAngle + (c / count) * g.arc;
+ const b = g.startAngle + ((c + 1) / count) * g.arc;
+ const inner = g.inner + r * g.radialHeight;
+ const outer = inner + g.radialHeight;
+ context.arc(g.cx, g.cy, inner, a, b);
+ context.arc(g.cx, g.cy, outer, b, a, true);
+ context.closePath();
+ }
+ context.fill();
+ context.strokeStyle = "rgba(45,52,45,.22)";
+ context.lineWidth = 0.5;
+ context.stroke();
+ }
+ if (increaseRows.has(r)) {
+ context.beginPath();
+ if (view === "chart") {
+ context.moveTo((canvas.width - count * g.cellWidth) / 2, bottom);
+ context.lineTo((canvas.width + count * g.cellWidth) / 2, bottom);
+ } else {
+ context.arc(
+ g.cx,
+ g.cy,
+ g.inner + r * g.radialHeight,
+ g.startAngle,
+ g.startAngle + g.arc,
+ );
+ }
+ context.strokeStyle = "#be7755";
+ context.lineWidth = 1.5;
+ context.stroke();
+ }
+ });
+ context.fillStyle = "#334c44";
+ context.font = "15px sans-serif";
+ context.textAlign = "center";
+ context.fillText(
+ view === "whole"
+ ? "Back · start of round"
+ : view === "repeat"
+ ? "Neck edge · round 1"
+ : "Neck edge · round 1 at bottom",
+ 500,
+ view === "chart" ? 790 : 15,
+ );
+ if (view === "whole") context.fillText("Front", 500, 790);
+ }, [project, view, aspect]);
+
+ function paint(event: PointerEvent) {
+ const box = event.currentTarget.getBoundingClientRect();
+ const cell = yokeCellAt(
+ counts,
+ project.measurements.repeats,
+ view,
+ aspect,
+ ((event.clientX - box.left) / box.width) * YOKE_CANVAS.width,
+ ((event.clientY - box.top) / box.height) * YOKE_CANVAS.height,
+ );
+ if (cell) onPaint(cell.row, cell.column);
+ }
+ return (
+
+ {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ onStrokeStart();
+ drawing.current = true;
+ event.currentTarget.setPointerCapture(event.pointerId);
+ paint(event);
+ }}
+ onPointerMove={(event) => {
+ if (drawing.current) paint(event);
+ }}
+ onPointerUp={() => {
+ drawing.current = false;
+ }}
+ onPointerCancel={() => {
+ drawing.current = false;
+ }}
+ onLostPointerCapture={() => {
+ drawing.current = false;
+ }}
+ />
+
+ );
+}
diff --git a/components/yoke-designer.tsx b/components/yoke-designer.tsx
new file mode 100644
index 0000000..240c9ac
--- /dev/null
+++ b/components/yoke-designer.tsx
@@ -0,0 +1,286 @@
+"use client";
+import Link from "next/link";
+import { useLayoutEffect, useRef, useState } from "react";
+import {
+ calculateYokeTargets,
+ defaultYokeMeasurements,
+ suggestYokeSchedule,
+ type YokeMeasurements,
+ type IncreaseEvent,
+} from "@/lib/yoke-plan";
+import {
+ createYokeProject,
+ paintYoke,
+ yokeProjectSchema,
+ type YokeProject,
+} from "@/lib/yoke-project";
+import type { YokeView } from "@/lib/yoke-layout";
+import { YokePlanner } from "./yoke-planner";
+import { YokeCanvas } from "./yoke-canvas";
+import { useYokeDraft } from "./use-yoke-draft";
+
+const initial = createYokeProject(
+ defaultYokeMeasurements,
+ suggestYokeSchedule(
+ calculateYokeTargets(defaultYokeMeasurements),
+ defaultYokeMeasurements.repeats,
+ ),
+);
+export function YokeDesigner() {
+ const { project, setProject, ready, error } = useYokeDraft(initial);
+ const [view, setView] = useState("repeat");
+ const [color, setColor] = useState(1);
+ const [allRepeats, setAllRepeats] = useState(true);
+ const [zoom, setZoom] = useState(100);
+ const [history, setHistory] = useState([]);
+ const [future, setFuture] = useState([]);
+ const [message, setMessage] = useState("");
+ const input = useRef(null);
+ const latestProject = useRef(project);
+ useLayoutEffect(() => {
+ latestProject.current = project;
+ }, [project]);
+ const plan = calculateYokeTargets(project.measurements);
+ function remember() {
+ setHistory((old) => [...old.slice(-19), project]);
+ setFuture([]);
+ }
+ function replace(next: YokeProject) {
+ if (
+ project.cells.some((row) => row.some((value) => value !== 0)) &&
+ !window.confirm(
+ "Replace this drawing with the new plan? Download the current yoke first if you want to keep it. You can also undo this change.",
+ )
+ )
+ return;
+ remember();
+ setProject(next);
+ setColor(1);
+ setMessage("");
+ }
+ function apply(measurements: YokeMeasurements, events: IncreaseEvent[]) {
+ replace({
+ ...createYokeProject(measurements, events),
+ palette: project.palette,
+ });
+ }
+ function download() {
+ const url = URL.createObjectURL(
+ new Blob([JSON.stringify(project)], { type: "application/json" }),
+ );
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "my-yoke.knitplot-yoke";
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }
+ if (!ready)
+ return (
+
+ Loading yoke draft…
+
+ );
+ return (
+
+
+
+
+ ◎ KnitPlot · Yoke designer
+
+
Development preview
+
+
+ Chart maker
+
+ Download yoke draft
+
+ input.current?.click()}
+ >
+ Open yoke draft
+
+
+ {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (!file) return;
+ try {
+ if (file.size > 2_000_000)
+ throw new Error("That draft is too large.");
+ const next = yokeProjectSchema.parse(
+ JSON.parse(await file.text()),
+ );
+ if (latestProject.current !== project) {
+ setMessage(
+ "Your drawing changed while the file was opening. Open the file again when you are ready to replace it.",
+ );
+ return;
+ }
+ replace(next);
+ } catch {
+ setMessage(
+ "That file is not a valid KnitPlot yoke draft. Your current drawing is unchanged.",
+ );
+ }
+ }}
+ />
+
+ {error || message ? {error || message}
: null}
+
+
+
+
+
+ {plan.neck} → {plan.end} stitches
+
+
+ {plan.rounds} full rounds · {project.measurements.repeats} repeats
+
+
+
+ {(
+ [
+ ["whole", "Whole yoke"],
+ ["repeat", "One repeat"],
+ ["chart", "Standard chart"],
+ ] as const
+ ).map(([id, label]) => (
+ setView(id)}
+ >
+ {label}
+
+ ))}
+
+
+ {project.palette.map((hex, index) => (
+
+ setColor(index)}
+ />
+ {
+ remember();
+ setProject({
+ ...project,
+ palette: project.palette.map((old, i) =>
+ i === index ? event.target.value : old,
+ ),
+ });
+ }}
+ />
+
+ ))}
+
+ setAllRepeats(event.target.checked)}
+ />
+ Edit all repeats
+
+
{
+ const previous = history[history.length - 1];
+ if (!previous) return;
+ setFuture((old) => [...old, project]);
+ setHistory(history.slice(0, -1));
+ setProject(previous);
+ }}
+ >
+ Undo
+
+
{
+ const next = future[future.length - 1];
+ if (!next) return;
+ setHistory((old) => [...old, project]);
+ setFuture(future.slice(0, -1));
+ setProject(next);
+ }}
+ >
+ Redo
+
+
+ Zoom
+ setZoom(Number(event.target.value))}
+ >
+ 100%
+ 150%
+ 200%
+ 300%
+
+
+
+
+ Design layout · warm lines mark increase rounds. Repeat and standard
+ views edit the first repeat; the whole-yoke view can edit any
+ repeat.
+
+
+ setProject((current) =>
+ paintYoke(current, row, column, color, allRepeats),
+ )
+ }
+ />
+
+ Current shaping plan
+
+ {project.events.map((event, index) => (
+
+ Round {event.round}: increase{" "}
+ {event.count -
+ (index ? project.events[index - 1].count : plan.neck)}{" "}
+ → {event.count} stitches (
+ {event.count / project.measurements.repeats} per repeat).
+
+ ))}
+
+
+ Body at split: {plan.bodyFromYoke} stitches. Each sleeve on hold:{" "}
+ {plan.sleeveFromYoke}. Cast on {plan.underarm} at each underarm.
+
+
+ {plan.shortRowPairs} short-row pairs planned before colourwork;
+ not included in these full-round drawing views.
+
+
+
+
+
+ );
+}
diff --git a/components/yoke-planner.tsx b/components/yoke-planner.tsx
new file mode 100644
index 0000000..cb08b43
--- /dev/null
+++ b/components/yoke-planner.tsx
@@ -0,0 +1,291 @@
+"use client";
+import { useState, type FormEvent } from "react";
+import { ZodError } from "zod";
+import {
+ calculateYokeTargets,
+ convertYokeUnits,
+ suggestYokeSchedule,
+ yokeRoundCounts,
+ type YokeMeasurements,
+ type IncreaseEvent,
+ type YokePlan,
+} from "@/lib/yoke-plan";
+
+type NumberKey = Exclude;
+const fields: { key: NumberKey; label: string; help?: string }[] = [
+ { key: "gaugeStitches", label: "Gauge stitches" },
+ { key: "gaugeRows", label: "Gauge rows" },
+ { key: "gaugeMeasure", label: "Gauge measured over" },
+ {
+ key: "neck",
+ label: "Finished neck opening",
+ help: "Circumference at the base of the neckband; neckband not included.",
+ },
+ { key: "chest", label: "Body chest / bust" },
+ { key: "bodyEase", label: "Body ease" },
+ { key: "upperArm", label: "Body upper arm" },
+ { key: "sleeveEase", label: "Sleeve ease" },
+ {
+ key: "underarm",
+ label: "Each underarm width",
+ help: "Width cast on at each underarm when separating sleeves.",
+ },
+ {
+ key: "depth",
+ label: "Full-round yoke length",
+ help: "Along the fabric from the base of the front neckline to the sleeve split. Not vertical armhole depth; excludes extra back short rows.",
+ },
+ {
+ key: "backRise",
+ label: "Extra back-neck height",
+ help: "Optional plain-colour short rows before the motif. Enter 0 to omit.",
+ },
+ { key: "repeats", label: "Repeats around yoke" },
+];
+function drafts(measurements: YokeMeasurements) {
+ return Object.fromEntries(
+ fields.map(({ key }) => [key, String(measurements[key])]),
+ ) as Record;
+}
+function read(
+ values: Record,
+ unit: "cm" | "in",
+): YokeMeasurements {
+ if (Object.values(values).some((value) => value.trim() === ""))
+ throw new Error(
+ "Fill in every measurement, using 0 for no ease or no short rows.",
+ );
+ return {
+ ...Object.fromEntries(
+ Object.entries(values).map(([key, value]) => [key, Number(value)]),
+ ),
+ unit,
+ } as YokeMeasurements;
+}
+type Candidate = {
+ measurements: YokeMeasurements;
+ targets: YokePlan;
+ source: string;
+};
+type Props = {
+ measurements: YokeMeasurements;
+ events: IncreaseEvent[];
+ onApply: (measurements: YokeMeasurements, events: IncreaseEvent[]) => void;
+};
+
+export function YokePlanner({ measurements, events, onApply }: Props) {
+ const [unit, setUnit] = useState(measurements.unit);
+ const [values, setValues] = useState(() => drafts(measurements));
+ const [candidate, setCandidate] = useState(null);
+ const [schedule, setSchedule] = useState(
+ events.map((event) => ({
+ round: String(event.round),
+ count: String(event.count),
+ })),
+ );
+ const [error, setError] = useState("");
+ const source = JSON.stringify({ values, unit });
+ const dirty = candidate !== null && candidate.source !== source;
+ const number = (value: number) => value.toFixed(1);
+ function calculate(event: FormEvent) {
+ event.preventDefault();
+ try {
+ const next = read(values, unit);
+ const targets = calculateYokeTargets(next);
+ setCandidate({ measurements: next, targets, source });
+ setSchedule(
+ suggestYokeSchedule(targets, next.repeats).map((entry) => ({
+ round: String(entry.round),
+ count: String(entry.count),
+ })),
+ );
+ setError("");
+ } catch (error) {
+ setError(
+ error instanceof Error && !(error instanceof ZodError)
+ ? error.message
+ : "Check the measurements: use finite numbers, positive gauge and lengths, and 2–40 whole repeats.",
+ );
+ }
+ }
+ function apply() {
+ if (!candidate || dirty) return;
+ try {
+ const nextEvents = schedule.map((event) => ({
+ round: Number(event.round),
+ count: Number(event.count),
+ }));
+ yokeRoundCounts(
+ candidate.targets,
+ candidate.measurements.repeats,
+ nextEvents,
+ );
+ onApply(candidate.measurements, nextEvents);
+ setError("");
+ } catch (error) {
+ setError(
+ error instanceof Error && !(error instanceof ZodError)
+ ? error.message
+ : "Enter valid whole rounds and stitch counts.",
+ );
+ }
+ }
+ return (
+
+ Plan the yoke
+
+ Top-down pullover · one consistent blocked gauge
+
+
+ {error ? {error}
: null}
+ {candidate ? (
+
+
Calculated targets
+
+ {candidate.targets.neck} → {candidate.targets.end} stitches ·{" "}
+ {candidate.targets.rounds} full rounds
+
+
+ Finished neck {number(candidate.targets.actualNeck)}{" "}
+ {candidate.measurements.unit} · body{" "}
+ {number(candidate.targets.actualBody)} {candidate.measurements.unit}{" "}
+ · upper sleeve {number(candidate.targets.actualSleeve)}{" "}
+ {candidate.measurements.unit}
+
+
+ Full-round fabric length: {number(candidate.targets.actualDepth)}{" "}
+ {candidate.measurements.unit}, before extra back-neck short rows.
+
+
+ Repeat rounding: neck{" "}
+ {candidate.targets.neckAdjustment >= 0 ? "+" : ""}
+ {candidate.targets.neckAdjustment} stitches; body{" "}
+ {candidate.targets.bodyAdjustment >= 0 ? "+" : ""}
+ {candidate.targets.bodyAdjustment} stitches. Sleeve targets
+ unchanged.
+
+
+ Separate {candidate.targets.bodyFromYoke} body stitches and{" "}
+ {candidate.targets.sleeveFromYoke} per sleeve. Add{" "}
+ {candidate.targets.underarm} at each underarm.
+
+
Increase schedule
+
+ Editable starting spacing, not a tested fitting prescription. Counts
+ are after working each increase round.
+
+
+ {schedule.map((entry, index) => (
+
+
+ Round
+
+ setSchedule(
+ schedule.map((old, i) =>
+ i === index
+ ? { ...old, round: event.target.value }
+ : old,
+ ),
+ )
+ }
+ />
+
+
+ Stitches after
+
+ setSchedule(
+ schedule.map((old, i) =>
+ i === index
+ ? { ...old, count: event.target.value }
+ : old,
+ ),
+ )
+ }
+ />
+
+
+ setSchedule(schedule.filter((_, i) => i !== index))
+ }
+ aria-label={`Remove increase ${index + 1}`}
+ >
+ ×
+
+
+ ))}
+
+
setSchedule([...schedule, { round: "", count: "" }])}
+ >
+ + Increase round
+
+
+ {candidate.targets.shortRowPairs
+ ? `Short-row planning allowance: ${candidate.targets.shortRowPairs} pairs (${number(candidate.targets.actualBackRise)} ${candidate.measurements.unit} extra at centre back). Turn positions and knitting instructions are not generated yet.`
+ : "No neckline short rows."}
+
+ {dirty ? (
+
+ Measurements changed. Calculate targets again before applying.
+
+ ) : null}
+
+ Apply plan to drawing
+
+
+ ) : null}
+
+ );
+}
diff --git a/docs/yoke-designer.md b/docs/yoke-designer.md
new file mode 100644
index 0000000..3097074
--- /dev/null
+++ b/docs/yoke-designer.md
@@ -0,0 +1,65 @@
+# Yoke designer: development slice
+
+Available at `/yoke-designer` in `next dev` only. Production must return 404.
+No production accounts, APIs, database tables or regular chart storage are changed.
+
+## Implemented
+
+- One consistent blocked stitch/row gauge. Knitters adjust needles to maintain it.
+- Targets from body chest + ease, upper arm + separate sleeve ease, finished
+ neckline, underarm width and full-round front yoke length along the fabric.
+- Explicit conversion between cm and inches, including the gauge measurement span.
+- Repeat-compatible neck and lower-yoke rounding, with resulting finished sizes
+ and stitch adjustments shown before applying.
+- Editable increase rounds with post-increase counts. Whole-repeat increments,
+ ascending rounds, final target and at-most-doubling constraints are validated.
+- Whole-yoke, single-repeat and standard chart drawing views sharing one model.
+ Ring and wedge views are design projections, not fit/physics simulations.
+ A repeat has an angular span of 2π / repeat count.
+- All-repeat painting or individual-repeat variations, zoom and undo/redo.
+- Versioned local draft, export/import `.knitplot-yoke`, schema validation,
+ quota warnings and optimistic comparison before browser-storage writes.
+ Other windows and damaged drafts must never be silently overwritten.
+
+## Stitch accounting
+
+For the simple top-down body/sleeve separation used here, B is the final body
+stitch count, S is each upper sleeve count and U is each underarm cast-on count.
+
+- Body from yoke = B − 2U
+- Each sleeve held from yoke = S − U
+- Total lower yoke = B + 2S − 4U
+
+Round neck and total lower-yoke counts to repeat multiples. Any rounding at the
+lower yoke is assigned to the body target and disclosed; sleeve targets stay fixed.
+This is an explicit arithmetic convention, not a universal sizing formula.
+
+Short-row allowance = desired extra back height × row gauge, rounded to pairs.
+This adds local back height only, NOT full rounds to every stitch or live stitches.
+
+## Deliberate limits / next decisions
+
+- The generated increase spacing is an editable engineering scaffold, not a
+ tested fit prescription. It is NOT taken from a published sweater pattern.
+ Internal arithmetic tests do not validate fit. Shoulder checkpoints, measurement
+ diagrams and comparisons against test-knitted designs are still required.
+- Short-row pair count is planning metadata only: no turn positions, double-stitch
+ operations, instructions or local-height rendering yet. First intended support:
+ plain-colour short rows before colourwork, without increases during those rows.
+- Shaping events presently specify resulting counts, not M1L/M1R/kfb placements.
+ No ordinary `.knitplot` export is offered until those placements and connections
+ are represented correctly. The existing realistic knitted renderer is unchanged.
+- Need an entry path for counts from an existing pattern, not measurements only.
+- Need measurement illustrations and shoulder/neckline fit controls, band-specific
+ repeats, selection tools, and integration with My Charts / Knit Mode.
+- Changing the applied structure resets the drawing only after confirmation if
+ there are painted cells; undo retains the prior project.
+- Limits: 1,200 stitches around, 200 per repeat, 150 full rounds, 2–40 repeats.
+ These limits are independent of the ordinary chart-maker limits.
+
+## Verification
+
+`lib/yoke-plan.test.ts` covers arithmetic, rounding, units, short-row separation,
+schedule consistency, immutable painting, file schema and rendering/hit-test
+agreement. `e2e/yoke.spec.ts` exercises the real UI in the isolated localhost
+harness; `e2e/editor.spec.ts` verifies the prototype is absent in production.
diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts
index 0b05758..075b718 100644
--- a/e2e/editor.spec.ts
+++ b/e2e/editor.spec.ts
@@ -101,6 +101,7 @@ test("development samples are not production routes", async ({ request }) => {
const response = await request.get("/stitch-samples/yarn");
expect(response.status()).toBe(404);
expect((await request.get("/e2e/harness")).status()).toBe(404);
+ expect((await request.get("/yoke-designer")).status()).toBe(404);
});
test("chart dimensions allow typing intermediate values and reset on tab changes", async ({
diff --git a/e2e/harness/app/yoke/page.tsx b/e2e/harness/app/yoke/page.tsx
new file mode 100644
index 0000000..5eed2e3
--- /dev/null
+++ b/e2e/harness/app/yoke/page.tsx
@@ -0,0 +1,6 @@
+import { YokeDesigner } from "../../../../components/yoke-designer";
+import "../../../../app/yoke-designer/yoke.css";
+
+export default function YokeHarness() {
+ return ;
+}
diff --git a/e2e/yoke.spec.ts b/e2e/yoke.spec.ts
new file mode 100644
index 0000000..05deebb
--- /dev/null
+++ b/e2e/yoke.spec.ts
@@ -0,0 +1,192 @@
+import { test, expect, type Page } from "@playwright/test";
+import type { YokeProject } from "../lib/yoke-project";
+import { yokeGeometry, type YokeView } from "../lib/yoke-layout";
+const KEY = "knitplot-yoke-draft-v1";
+const saved = (page: Page) =>
+ page.evaluate(
+ (key) => JSON.parse(localStorage.getItem(key)!) as YokeProject,
+ KEY,
+ );
+async function paint(
+ page: Page,
+ project: YokeProject,
+ view: YokeView,
+ row: number,
+ column: number,
+) {
+ const canvas = page.getByRole("img", { name: /drawing canvas/ });
+ const box = (await canvas.boundingBox())!;
+ const g = yokeGeometry(
+ project.cells.map((row) => row.length),
+ project.measurements.repeats,
+ view,
+ project.measurements.gaugeRows / project.measurements.gaugeStitches,
+ );
+ const angle = g.startAngle + ((column + 0.5) / g.shownCounts[row]) * g.arc;
+ const radius = g.inner + (row + 0.5) * g.radialHeight;
+ const x =
+ view === "chart"
+ ? (1000 - g.shownCounts[row] * g.cellWidth) / 2 +
+ (column + 0.5) * g.cellWidth
+ : g.cx + Math.cos(angle) * radius;
+ const y =
+ view === "chart"
+ ? 760 - (row + 0.5) * g.rowHeight
+ : g.cy + Math.sin(angle) * radius;
+ await canvas.click({
+ position: { x: (x / 1000) * box.width, y: (y / 800) * box.height },
+ });
+}
+test.beforeEach(async ({ page }) => {
+ await page.goto("/yoke");
+ await expect(
+ page.getByRole("heading", { name: /KnitPlot · Yoke designer/ }),
+ ).toBeVisible();
+ await expect
+ .poll(async () => (await saved(page))?.format)
+ .toBe("knitplot-yoke");
+});
+
+test("all views share yarn cells, repeat editing, zoom, history and saved drafts", async ({
+ page,
+}) => {
+ const original = await saved(page);
+ await paint(page, original, "repeat", 2, 1);
+ await expect
+ .poll(
+ async () => (await saved(page)).cells[2].filter((c) => c === 1).length,
+ )
+ .toBe(12);
+ await page.getByRole("button", { name: "Whole yoke", exact: true }).click();
+ await page.getByLabel("Edit all repeats").uncheck();
+ await page.getByRole("button", { name: "Paint with yarn colour 3" }).click();
+ await paint(page, original, "whole", 2, 2);
+ await expect
+ .poll(
+ async () => (await saved(page)).cells[2].filter((c) => c === 2).length,
+ )
+ .toBe(1);
+ await page
+ .getByRole("button", { name: "Standard chart", exact: true })
+ .click();
+ await page.getByLabel("Yoke zoom", { exact: true }).selectOption("200");
+ await paint(page, original, "chart", original.cells.length - 1, 0);
+ await expect.poll(async () => (await saved(page)).cells.at(-1)![0]).toBe(2);
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect.poll(async () => (await saved(page)).cells.at(-1)![0]).toBe(0);
+ await page.getByRole("button", { name: "Redo", exact: true }).click();
+ await expect.poll(async () => (await saved(page)).cells.at(-1)![0]).toBe(2);
+ await page.reload();
+ await expect(
+ page.getByRole("heading", { name: /KnitPlot · Yoke designer/ }),
+ ).toBeVisible();
+ expect((await saved(page)).cells.at(-1)![0]).toBe(2);
+});
+
+test("measurement drafts accept intermediate values, validate schedule, and preserve work until applied", async ({
+ page,
+}) => {
+ const original = await saved(page);
+ await page.getByLabel("Repeats around yoke", { exact: true }).fill("1");
+ await expect(
+ page.getByLabel("Repeats around yoke", { exact: true }),
+ ).toHaveValue("1");
+ await page.getByLabel("Repeats around yoke", { exact: true }).fill("10");
+ await page
+ .getByRole("button", { name: "Calculate targets", exact: true })
+ .click();
+ await expect(
+ page.getByRole("heading", { name: "Calculated targets" }),
+ ).toBeVisible();
+ expect((await saved(page)).measurements.repeats).toBe(
+ original.measurements.repeats,
+ );
+ await page.getByLabel("Increase 1 round", { exact: true }).fill("1");
+ await page.getByRole("button", { name: "Apply plan to drawing" }).click();
+ await expect(page.locator(".yoke-designer [role=alert]")).toContainText(
+ "valid whole rounds",
+ );
+ await page
+ .getByRole("button", { name: "Calculate targets", exact: true })
+ .click();
+ await page.getByRole("button", { name: "Apply plan to drawing" }).click();
+ await expect
+ .poll(async () => (await saved(page)).measurements.repeats)
+ .toBe(10);
+});
+
+test("a second window cannot silently overwrite the first window's yoke draft", async ({
+ page,
+ context,
+}) => {
+ const second = await context.newPage();
+ await second.goto("/yoke");
+ await expect(
+ second.getByRole("heading", { name: /KnitPlot · Yoke designer/ }),
+ ).toBeVisible();
+ const original = await saved(page);
+ await paint(page, original, "repeat", 1, 1);
+ await expect
+ .poll(
+ async () => (await saved(page)).cells[1].filter((c) => c === 1).length,
+ )
+ .toBe(12);
+ await second
+ .getByRole("button", { name: "Paint with yarn colour 3" })
+ .click();
+ await paint(second, original, "repeat", 1, 2);
+ await expect(second.locator(".yoke-designer [role=alert]")).toContainText(
+ "another window changed it",
+ );
+ expect((await saved(page)).cells[1].filter((c) => c === 2).length).toBe(0);
+});
+
+test("yoke draft download roundtrips through the validated file importer", async ({
+ page,
+}) => {
+ const original = await saved(page);
+ await paint(page, original, "repeat", 1, 1);
+ await expect.poll(async () => (await saved(page)).cells[1][1]).toBe(1);
+ const expected = await saved(page);
+ const event = page.waitForEvent("download");
+ await page
+ .getByRole("button", { name: "Download yoke draft", exact: true })
+ .click();
+ const download = await event;
+ const stream = await download.createReadStream();
+ const chunks: Buffer[] = [];
+ for await (const chunk of stream!) chunks.push(Buffer.from(chunk));
+ const buffer = Buffer.concat(chunks);
+ expect(JSON.parse(buffer.toString("utf8"))).toEqual(expected);
+ await page.getByRole("button", { name: "Undo", exact: true }).click();
+ await expect.poll(async () => (await saved(page)).cells[1][1]).toBe(0);
+ await page.locator('input[type="file"]').setInputFiles({
+ name: "test.knitplot-yoke",
+ mimeType: "application/json",
+ buffer,
+ });
+ await expect.poll(async () => (await saved(page)).cells[1][1]).toBe(1);
+ expect(await saved(page)).toEqual(expected);
+});
+
+test("corrupt yoke drafts are not overwritten and normal chart storage is untouched", async ({
+ page,
+}) => {
+ await page.evaluate((key) => {
+ localStorage.setItem(key, "not-json");
+ localStorage.setItem("colorwork-chart-v1", "separate chart workspace");
+ }, KEY);
+ await page.reload();
+ await expect(page.locator(".yoke-designer [role=alert]")).toContainText(
+ /could not be/,
+ );
+ await expect(
+ page.getByRole("button", { name: "Download yoke draft", exact: true }),
+ ).toBeVisible();
+ expect(await page.evaluate((key) => localStorage.getItem(key), KEY)).toBe(
+ "not-json",
+ );
+ expect(
+ await page.evaluate(() => localStorage.getItem("colorwork-chart-v1")),
+ ).toBe("separate chart workspace");
+});
diff --git a/playwright.config.ts b/playwright.config.ts
index a27e3f7..4be6a0c 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -25,7 +25,7 @@ export default defineConfig({
},
{
name: "saving",
- testMatch: "saving.spec.ts",
+ testMatch: ["saving.spec.ts", "yoke.spec.ts"],
use: { ...devices["Desktop Chrome"], baseURL: "http://127.0.0.1:3109" },
},
],
From 43502ec5570e6e087ed119063c637e6ad8ad02ef Mon Sep 17 00:00:00 2001
From: syalr2
Date: Wed, 11 Nov 2026 20:07:29 -0500
Subject: [PATCH 3/3] Ship the circular yoke designer and front sweater preview
---
app/globals.css | 117 +-
app/yoke-designer/yoke.css | 385 ++++-
components/chart-grid.tsx | 15 +-
components/chart-maker.tsx | 895 ++++++-----
components/cloud-chart-actions.tsx | 5 +-
components/connected-knit-preview.tsx | 156 +-
components/export-tools.tsx | 58 +-
components/instructions-view.tsx | 468 +++---
components/knit-mode.tsx | 571 +++++--
components/my-charts-library.tsx | 2 +
components/use-device-pixel-ratio.ts | 24 +
components/use-yoke-draft.ts | 56 +-
components/use-yoke-navigation.ts | 222 +++
components/use-yoke-stitches.ts | 349 ++++
components/use-yoke-tools.ts | 193 +++
components/yoke-canvas-label.ts | 18 +
components/yoke-canvas-render.ts | 209 +++
components/yoke-canvas.tsx | 351 +++--
components/yoke-designer.tsx | 165 +-
components/yoke-drawing-tools.tsx | 195 +++
components/yoke-editor.tsx | 441 ++++++
components/yoke-gauge-panel.tsx | 142 ++
components/yoke-instructions-sheet.tsx | 96 ++
components/yoke-knitted-preview.tsx | 111 ++
components/yoke-measurement-help.tsx | 356 +++++
components/yoke-measurement-point.tsx | 48 +
components/yoke-photo-sample.tsx | 93 ++
components/yoke-plan-dialog.tsx | 268 ++++
components/yoke-planner.tsx | 413 +++--
components/yoke-program-canvas-render.ts | 201 +++
components/yoke-repeat-controls.tsx | 215 +++
components/yoke-rounding-review.tsx | 64 +
components/yoke-schedule-editor.tsx | 143 ++
components/yoke-short-row-planner.tsx | 169 ++
components/yoke-standard-chart.tsx | 62 +
components/yoke-stitch-program-grid.tsx | 137 ++
components/yoke-stitch-revision-dialog.tsx | 150 ++
database/tests/persistence.test.mjs | 89 ++
docs/yoke-designer.md | 231 ++-
docs/yoke-illustration-prompts.md | 19 +
docs/yoke-short-rows.md | 23 +
docs/yoke-stitch-revisions.md | 112 ++
e2e/editor.spec.ts | 20 +
e2e/yoke-editor.spec.ts | 1573 +++++++++++++++++++
e2e/yoke-preview.spec.ts | 485 ++++++
e2e/yoke.spec.ts | 570 ++++++-
lib/chart-route-handlers.test.ts | 14 +
lib/chart-route-handlers.ts | 17 +-
lib/chart-schema.ts | 106 +-
lib/chart-summary.ts | 2 +
lib/chart.ts | 2 +
lib/cloud-charts.ts | 17 +-
lib/cloud-save.ts | 3 +-
lib/connected-preview.ts | 98 +-
lib/connected-preview.worker.ts | 12 +-
lib/gauge-schema.ts | 7 +
lib/http.ts | 39 +-
lib/knit-sequence.ts | 81 +
lib/preview-freshness.ts | 55 +
lib/preview-layout-request.ts | 7 +
lib/project-file.ts | 4 +
lib/stitch-schema.ts | 19 +
lib/workspace-persistence.ts | 3 +-
lib/workspace.test.ts | 23 +
lib/workspace.ts | 65 +-
lib/yoke-canvas-view.ts | 55 +
lib/yoke-chart.test.ts | 208 +++
lib/yoke-chart.ts | 84 +
lib/yoke-document.test.ts | 95 ++
lib/yoke-document.ts | 128 ++
lib/yoke-editing.test.ts | 318 ++++
lib/yoke-editing.ts | 341 ++++
lib/yoke-fabric-preview.test.ts | 160 ++
lib/yoke-fabric-preview.ts | 290 ++++
lib/yoke-fill.ts | 96 ++
lib/yoke-history.test.ts | 60 +
lib/yoke-history.ts | 24 +
lib/yoke-knit.test.ts | 236 +++
lib/yoke-knit.ts | 246 +++
lib/yoke-layout.ts | 32 +-
lib/yoke-measurement-fields.ts | 144 ++
lib/yoke-measurement-mode.test.ts | 79 +
lib/yoke-measurement-mode.ts | 55 +
lib/yoke-mirror.test.ts | 79 +
lib/yoke-mirror.ts | 144 ++
lib/yoke-photo-preview.test.ts | 151 ++
lib/yoke-photo-preview.ts | 267 ++++
lib/yoke-plan.ts | 201 ++-
lib/yoke-preview-view.ts | 20 +
lib/yoke-program-chart.ts | 221 +++
lib/yoke-program-drawing.ts | 92 ++
lib/yoke-program-from-project.ts | 34 +
lib/yoke-program-integration.test.ts | 222 +++
lib/yoke-program-state.ts | 114 ++
lib/yoke-program-surface.ts | 71 +
lib/yoke-program-wire.test.ts | 190 +++
lib/yoke-program-wire.ts | 103 ++
lib/yoke-project.ts | 104 +-
lib/yoke-repeat-labels.test.ts | 46 +
lib/yoke-repeat-labels.ts | 45 +
lib/yoke-repeat-mirror.ts | 19 +
lib/yoke-repeat-scope.ts | 12 +
lib/yoke-rounding.test.ts | 47 +
lib/yoke-rounding.ts | 71 +
lib/yoke-shaping.test.ts | 116 ++
lib/yoke-shaping.ts | 187 +++
lib/yoke-short-row-layout.test.ts | 61 +
lib/yoke-short-row-layout.ts | 74 +
lib/yoke-short-rows.test.ts | 153 ++
lib/yoke-short-rows.ts | 107 ++
lib/yoke-stitch-program-schema.ts | 62 +
lib/yoke-stitch-program.test.ts | 189 +++
lib/yoke-stitch-program.ts | 221 +++
lib/yoke-stitch-revision.ts | 182 +++
lib/yoke-sweater-preview.test.ts | 93 ++
lib/yoke-sweater-preview.ts | 291 ++++
lib/yoke-test-fixtures.ts | 75 +
lib/yoke-validation.test.ts | 26 +
lib/yoke-view-transform.test.ts | 88 ++
lib/yoke-view-transform.ts | 37 +
package-lock.json | 13 +
package.json | 1 +
playwright.config.ts | 7 +-
public/images/yoke/back-rise-diagram-v2.png | Bin 0 -> 1149796 bytes
public/images/yoke/body-diagram.png | Bin 0 -> 1305393 bytes
public/images/yoke/photo-sweater-front.webp | Bin 0 -> 489786 bytes
public/images/yoke/sweater-diagram.png | Bin 0 -> 1431077 bytes
public/images/yoke/underarm-diagram.png | Bin 0 -> 1352603 bytes
128 files changed, 16879 insertions(+), 1267 deletions(-)
create mode 100644 components/use-device-pixel-ratio.ts
create mode 100644 components/use-yoke-navigation.ts
create mode 100644 components/use-yoke-stitches.ts
create mode 100644 components/use-yoke-tools.ts
create mode 100644 components/yoke-canvas-label.ts
create mode 100644 components/yoke-canvas-render.ts
create mode 100644 components/yoke-drawing-tools.tsx
create mode 100644 components/yoke-editor.tsx
create mode 100644 components/yoke-gauge-panel.tsx
create mode 100644 components/yoke-instructions-sheet.tsx
create mode 100644 components/yoke-knitted-preview.tsx
create mode 100644 components/yoke-measurement-help.tsx
create mode 100644 components/yoke-measurement-point.tsx
create mode 100644 components/yoke-photo-sample.tsx
create mode 100644 components/yoke-plan-dialog.tsx
create mode 100644 components/yoke-program-canvas-render.ts
create mode 100644 components/yoke-repeat-controls.tsx
create mode 100644 components/yoke-rounding-review.tsx
create mode 100644 components/yoke-schedule-editor.tsx
create mode 100644 components/yoke-short-row-planner.tsx
create mode 100644 components/yoke-standard-chart.tsx
create mode 100644 components/yoke-stitch-program-grid.tsx
create mode 100644 components/yoke-stitch-revision-dialog.tsx
create mode 100644 docs/yoke-illustration-prompts.md
create mode 100644 docs/yoke-short-rows.md
create mode 100644 docs/yoke-stitch-revisions.md
create mode 100644 e2e/yoke-editor.spec.ts
create mode 100644 e2e/yoke-preview.spec.ts
create mode 100644 lib/gauge-schema.ts
create mode 100644 lib/knit-sequence.ts
create mode 100644 lib/preview-freshness.ts
create mode 100644 lib/stitch-schema.ts
create mode 100644 lib/yoke-canvas-view.ts
create mode 100644 lib/yoke-chart.test.ts
create mode 100644 lib/yoke-chart.ts
create mode 100644 lib/yoke-document.test.ts
create mode 100644 lib/yoke-document.ts
create mode 100644 lib/yoke-editing.test.ts
create mode 100644 lib/yoke-editing.ts
create mode 100644 lib/yoke-fabric-preview.test.ts
create mode 100644 lib/yoke-fabric-preview.ts
create mode 100644 lib/yoke-fill.ts
create mode 100644 lib/yoke-history.test.ts
create mode 100644 lib/yoke-history.ts
create mode 100644 lib/yoke-knit.test.ts
create mode 100644 lib/yoke-knit.ts
create mode 100644 lib/yoke-measurement-fields.ts
create mode 100644 lib/yoke-measurement-mode.test.ts
create mode 100644 lib/yoke-measurement-mode.ts
create mode 100644 lib/yoke-mirror.test.ts
create mode 100644 lib/yoke-mirror.ts
create mode 100644 lib/yoke-photo-preview.test.ts
create mode 100644 lib/yoke-photo-preview.ts
create mode 100644 lib/yoke-preview-view.ts
create mode 100644 lib/yoke-program-chart.ts
create mode 100644 lib/yoke-program-drawing.ts
create mode 100644 lib/yoke-program-from-project.ts
create mode 100644 lib/yoke-program-integration.test.ts
create mode 100644 lib/yoke-program-state.ts
create mode 100644 lib/yoke-program-surface.ts
create mode 100644 lib/yoke-program-wire.test.ts
create mode 100644 lib/yoke-program-wire.ts
create mode 100644 lib/yoke-repeat-labels.test.ts
create mode 100644 lib/yoke-repeat-labels.ts
create mode 100644 lib/yoke-repeat-mirror.ts
create mode 100644 lib/yoke-repeat-scope.ts
create mode 100644 lib/yoke-rounding.test.ts
create mode 100644 lib/yoke-rounding.ts
create mode 100644 lib/yoke-shaping.test.ts
create mode 100644 lib/yoke-shaping.ts
create mode 100644 lib/yoke-short-row-layout.test.ts
create mode 100644 lib/yoke-short-row-layout.ts
create mode 100644 lib/yoke-short-rows.test.ts
create mode 100644 lib/yoke-short-rows.ts
create mode 100644 lib/yoke-stitch-program-schema.ts
create mode 100644 lib/yoke-stitch-program.test.ts
create mode 100644 lib/yoke-stitch-program.ts
create mode 100644 lib/yoke-stitch-revision.ts
create mode 100644 lib/yoke-sweater-preview.test.ts
create mode 100644 lib/yoke-sweater-preview.ts
create mode 100644 lib/yoke-test-fixtures.ts
create mode 100644 lib/yoke-validation.test.ts
create mode 100644 lib/yoke-view-transform.test.ts
create mode 100644 lib/yoke-view-transform.ts
create mode 100644 public/images/yoke/back-rise-diagram-v2.png
create mode 100644 public/images/yoke/body-diagram.png
create mode 100644 public/images/yoke/photo-sweater-front.webp
create mode 100644 public/images/yoke/sweater-diagram.png
create mode 100644 public/images/yoke/underarm-diagram.png
diff --git a/app/globals.css b/app/globals.css
index 0e7f9e3..2278181 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -395,6 +395,25 @@ button:disabled {
cursor: default;
opacity: 0.42;
}
+.sidebar .secondary-button,
+.sidebar .resize-chart-button,
+.yoke-repeat-navigation .secondary-button,
+.yoke-editing-scope .secondary-button {
+ transition:
+ background-color 0.22s ease,
+ border-color 0.22s ease,
+ color 0.22s ease;
+}
+.sidebar .secondary-button:hover:not(:disabled),
+.sidebar .resize-chart-button:hover:not(:disabled),
+.yoke-repeat-navigation .secondary-button:hover:not(:disabled),
+.yoke-editing-scope .secondary-button:hover:not(:disabled) {
+ background-color: #f4f5f0;
+ border-color: #c5cabb;
+ box-shadow: 0 1px 2px rgba(60, 50, 35, 0.04);
+ color: var(--ink);
+ transform: none;
+}
.primary-button {
background: var(--accent);
border-color: var(--accent);
@@ -3027,6 +3046,13 @@ button:disabled {
padding: 18px;
align-items: start;
}
+.chart-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ min-width: 0;
+ width: 100%;
+}
.panel {
background: var(--panel);
@@ -3354,7 +3380,7 @@ select:focus {
.editor {
display: flex;
flex-direction: column;
- min-height: 620px;
+ min-height: 0;
overflow: hidden;
}
.panel-heading {
@@ -3638,6 +3664,9 @@ select:focus {
height: 31px;
width: 49px;
}
+.repeat-controls .yoke-preview-view-select {
+ width: 130px;
+}
.repeat-controls .repeat-style select {
width: 88px;
}
@@ -3858,6 +3887,56 @@ select:focus {
font-size: 10px;
line-height: 1.4;
}
+.knit-section-size {
+ align-items: center;
+ color: var(--muted);
+ display: flex;
+ font-size: 11px;
+ gap: 8px;
+ justify-content: space-between;
+}
+.knit-section-size select {
+ flex: 0 0 64px;
+ font-size: 12px;
+ height: 28px;
+ min-height: 28px;
+ padding: 3px 7px;
+ width: 64px;
+}
+.knit-section-location {
+ align-items: baseline;
+ color: var(--muted);
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 11px;
+ gap: 6px 12px;
+ justify-content: space-between;
+}
+.knit-jump-control summary {
+ color: var(--accent);
+ cursor: pointer;
+ font-size: 11px;
+}
+.knit-jump-control[open] {
+ flex-basis: 100%;
+}
+.knit-jump-fields {
+ display: grid;
+ gap: 10px;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ margin-top: 6px;
+}
+.knit-jump-fields label {
+ font-size: 11px;
+ gap: 3px;
+ min-width: 0;
+}
+.knit-jump-fields select {
+ font-size: 12px;
+ height: 28px;
+ min-height: 28px;
+ padding: 3px 7px;
+}
.knit-mode-settings-grid {
display: grid;
gap: 8px;
@@ -4007,8 +4086,42 @@ select:focus {
.knit-row-controls button {
min-height: 46px;
}
+.knit-step-controls button {
+ font-size: 12px;
+ line-height: 1.35;
+ min-height: 34px;
+ padding: 5px 8px;
+}
.knit-reset {
background: transparent;
+ border-color: var(--line-soft);
+ border-radius: 999px;
+ box-shadow: none;
+ color: var(--muted);
+ font-size: 12px;
+ justify-self: center;
+ line-height: 1.4;
+ max-width: 100%;
+ min-height: 32px;
+ padding: 6px 14px;
+}
+.knit-reset:hover:not(:disabled) {
+ border-color: var(--line);
+ box-shadow: none;
+ transform: none;
+}
+.knit-short-row-prelude {
+ color: var(--ink);
+ font-size: 12px;
+ line-height: 1.5;
+}
+.knit-short-row-prelude > p {
+ margin: 0 0 4px;
+}
+.knit-short-row-prelude summary {
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 11px;
}
.knit-keyboard-hint {
color: var(--muted);
@@ -4773,7 +4886,7 @@ select:focus {
width: 100%;
}
.editor {
- min-height: 560px;
+ min-height: 0;
order: 1;
}
.preview {
diff --git a/app/yoke-designer/yoke.css b/app/yoke-designer/yoke.css
index 20875bb..112f76c 100644
--- a/app/yoke-designer/yoke.css
+++ b/app/yoke-designer/yoke.css
@@ -3,6 +3,111 @@
margin: auto;
padding: 24px;
}
+.yoke-plan-dialog {
+ width: min(1250px, 95vw);
+ max-width: 1250px;
+ max-height: 92vh;
+ overflow: auto;
+}
+.yoke-plan-comparison {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 16px;
+ margin: 20px 0;
+}
+.yoke-plan-comparison figure {
+ min-width: 0;
+ margin: 0;
+}
+.yoke-plan-comparison figcaption {
+ font-weight: 600;
+ margin-bottom: 8px;
+}
+.yoke-transfer-options {
+ padding: 16px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+}
+.yoke-transfer-options label {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin: 8px 20px 8px 0;
+}
+.yoke-gauge-field {
+ display: grid;
+ gap: 5px;
+ margin: 8px 0;
+}
+.yoke-gauge-field input,
+.yoke-gauge-field select {
+ min-width: 0;
+ width: 100%;
+}
+.yoke-gauge-panel .yoke-hint {
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.5;
+ margin: 8px 0 12px;
+}
+.yoke-gauge-panel .yoke-update-gauge {
+ font-size: 12px;
+ min-height: 32px;
+ width: 100%;
+}
+.yoke-gauge-panel .text-button {
+ font-size: 11px;
+ line-height: 1.5;
+ margin-top: 8px;
+ text-align: left;
+}
+.yoke-editing-scope .secondary-button {
+ font-size: 12px;
+ min-height: 32px;
+}
+.editor > .yoke-view-tabs {
+ margin: 0;
+ padding: 16px 20px 6px;
+}
+.yoke-editor-panel .yoke-view-tabs {
+ gap: 12px 18px;
+}
+.yoke-editor-panel .yoke-view-tabs > select {
+ font-size: 12px;
+ min-height: 32px;
+ width: auto;
+}
+.yoke-editor-panel .yoke-repeat-toggle {
+ margin-left: 0;
+ font-size: 12px;
+ width: auto;
+}
+.yoke-plan-summary p {
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.5;
+ margin: 6px 0;
+}
+.yoke-plan-summary p:first-child {
+ color: var(--ink);
+ font-size: 13px;
+ font-weight: 500;
+}
+.yoke-plan-summary button {
+ font-size: 12px;
+ min-height: 32px;
+ margin-top: 6px;
+}
+.yoke-zoom-controls select {
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+ color: var(--muted);
+ font-size: 12px;
+ min-height: 32px;
+ padding: 3px;
+ width: 65px;
+}
.yoke-header,
.yoke-header nav,
.yoke-toolbar,
@@ -30,10 +135,29 @@
}
.yoke-workspace {
display: grid;
- grid-template-columns: 340px minmax(0, 1fr);
+ grid-template-columns: minmax(0, 1fr);
gap: 20px;
align-items: start;
}
+.yoke-short-row-inputs {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
+ gap: 12px;
+ margin: 16px 0;
+}
+.yoke-short-row-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.yoke-short-row-map {
+ display: block;
+ max-width: 700px;
+ width: 100%;
+ margin: 20px auto;
+ font: 12px sans-serif;
+ fill: var(--ink);
+}
.yoke-panel {
background: var(--panel);
border: 1px solid var(--line);
@@ -41,6 +165,15 @@
padding: 20px;
min-width: 0;
}
+.yoke-planner-measurements {
+ display: grid;
+ grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
+ align-items: start;
+ gap: 24px;
+}
+.yoke-planner-measurements .yoke-measurement-guide {
+ position: static;
+}
.yoke-panel h2 {
font-size: 22px;
margin: 0 0 8px;
@@ -88,6 +221,26 @@
.yoke-panel small {
line-height: 1.4;
}
+.yoke-measurement-control {
+ display: grid;
+ gap: 8px;
+ margin: 16px 0;
+ font-size: 12px;
+}
+.yoke-panel .yoke-measurement-toggle {
+ display: flex;
+ justify-content: flex-start;
+ gap: 10px;
+ white-space: normal;
+}
+.yoke-measurement-toggle .knit-toggle-track {
+ flex-shrink: 0;
+}
+.yoke-measurement-toggle input:not(:checked) ~ span:last-child,
+.yoke-measurement-toggle:has(input:checked) > span:first-child {
+ color: var(--muted);
+ font-weight: 400;
+}
.yoke-schedule > div {
display: grid;
grid-template-columns: 1fr 1fr 30px;
@@ -133,6 +286,37 @@
overflow: auto;
background: white;
border-radius: 8px;
+ max-height: 75vh;
+}
+.yoke-editing-scope {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px 18px;
+ padding: 12px 20px;
+ font-size: 13px;
+}
+.yoke-editing-scope label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.yoke-repeat-notice {
+ font-size: 13px;
+ margin: 0;
+ padding: 0 20px 12px;
+ color: #675b47;
+}
+.yoke-selection-tools {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 5px;
+ width: 100%;
+}
+.yoke-selection-tools button {
+ font-size: 12px;
+ min-height: 30px;
}
.yoke-canvas-scroll canvas {
display: block;
@@ -142,11 +326,210 @@
user-select: none;
cursor: crosshair;
}
+.yoke-canvas-navigable {
+ height: min(70vh, 700px);
+ overscroll-behavior: contain;
+}
+.yoke-canvas-navigable canvas {
+ margin-inline: auto;
+}
+.yoke-repeat-navigation {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ max-width: 100%;
+ gap: 12px;
+ padding: 0;
+}
+.yoke-repeat-navigation > button {
+ font-size: 12px;
+ min-height: 32px;
+}
.yoke-drawing details {
margin-top: 14px;
font-size: 13px;
}
+.yoke-measurement-help {
+ margin: 12px 0;
+ font-size: 12px;
+}
+.yoke-measurement-help svg {
+ display: block;
+ max-width: 260px;
+ margin: 8px auto;
+}
+.yoke-schedule-editor fieldset {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ margin: 12px 0;
+ padding: 10px;
+ min-width: 0;
+}
+.yoke-schedule-editor legend {
+ font-size: 12px;
+ font-weight: 600;
+}
+.yoke-schedule-editor label {
+ margin-bottom: 8px;
+}
+.yoke-schedule-editor small {
+ display: block;
+ overflow-wrap: anywhere;
+ margin-bottom: 8px;
+}
+.yoke-standard-chart {
+ min-width: 0;
+}
+.yoke-pending-shaping {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 0 20px 14px;
+ padding: 12px;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ background: var(--accent-soft);
+ font-size: 12px;
+}
+.yoke-pending-shaping button {
+ font-size: 12px;
+ min-height: 32px;
+}
+.yoke-measurement-guide {
+ margin-bottom: 20px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #faf8f1;
+}
+.yoke-measurement-guide summary {
+ cursor: pointer;
+ font-weight: 600;
+}
+.yoke-measurement-diagrams {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+}
+.yoke-measurement-diagrams figure {
+ margin: 0 auto;
+ width: 100%;
+ max-width: 340px;
+}
+.yoke-measurement-diagrams figcaption {
+ font-size: 13px;
+ text-align: center;
+ padding: 8px 0;
+}
+.yoke-measurement-diagrams svg {
+ width: 100%;
+ display: block;
+}
+.measurement-outline {
+ fill: #ece9df;
+ stroke: #5b6258;
+ stroke-width: 1.5;
+ stroke-linejoin: round;
+}
+.measurement-lines {
+ fill: none;
+ stroke: #bba78c;
+ stroke-width: 1.8;
+}
+.measurement-lines.is-active {
+ stroke: #b45439;
+ stroke-width: 3;
+}
+.measurement-point {
+ cursor: pointer;
+}
+.measurement-point circle {
+ fill: #fff;
+ stroke: #7b8979;
+ stroke-width: 1.5;
+}
+.measurement-point text {
+ fill: #334c44;
+ font: 600 13px sans-serif;
+}
+.measurement-point.is-active circle,
+.measurement-point:focus circle {
+ fill: #334c44;
+ stroke: #334c44;
+}
+.measurement-point.is-active text,
+.measurement-point:focus text {
+ fill: white;
+}
+.measurement-point[aria-disabled="true"] {
+ opacity: 0.45;
+ cursor: default;
+}
+.measurement-labels {
+ fill: #555c50;
+ font: 11px sans-serif;
+}
+.measurement-detail {
+ align-self: start;
+}
+.measurement-detail p {
+ margin: 4px 0 0;
+ font-size: 12px;
+ line-height: 1.5;
+}
+.measurement-guide-selection {
+ border-left: 3px solid #b45439;
+ padding: 8px 12px;
+ margin: 12px 0;
+}
+.measurement-guide-selection p {
+ margin: 6px 0 0;
+}
+.measurement-guide-legend {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+.measurement-guide-legend button {
+ font-size: 11px;
+ padding: 6px 9px;
+}
+.measurement-guide-legend button[aria-pressed="true"] {
+ color: white;
+ background: #334c44;
+}
+@media (max-width: 500px) {
+ .yoke-measurement-diagrams {
+ grid-template-columns: 1fr;
+ }
+}
+.yoke-rounding-review {
+ border: 1px solid #c79d63;
+ border-radius: 8px;
+ padding: 12px;
+ margin: 12px 0;
+ background: #fff8eb;
+}
+.yoke-rounding-review button {
+ display: block;
+ margin-bottom: 8px;
+ font-size: 12px;
+ text-align: left;
+}
+.yoke-rounding-review label {
+ display: flex;
+ align-items: center;
+ margin-top: 12px;
+}
@media (max-width: 900px) {
+ .yoke-planner-measurements {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .yoke-planner-measurements .yoke-measurement-guide {
+ position: static;
+ grid-row: 1;
+ }
.yoke-workspace {
grid-template-columns: 1fr;
}
diff --git a/components/chart-grid.tsx b/components/chart-grid.tsx
index d083a76..fb891ed 100644
--- a/components/chart-grid.tsx
+++ b/components/chart-grid.tsx
@@ -24,6 +24,8 @@ type Props = {
onSelectionChange: (selection: CellSelection | null) => void;
onStrokeStart: () => void;
onPaint: (row: number, column: number, flood: boolean) => void;
+ yarnOnly?: boolean;
+ onStrokeEnd?: () => void;
};
export function ChartGrid({
@@ -35,9 +37,12 @@ export function ChartGrid({
onSelectionChange,
onStrokeStart,
onPaint,
+ yarnOnly = false,
+ onStrokeEnd,
}: Props) {
const svgRef = useRef(null);
const drawing = useRef(false);
+ const strokeActive = useRef(false);
const selecting = useRef(false);
const selectionStart = useRef<{ row: number; column: number } | null>(null);
const previousCell = useRef("");
@@ -87,6 +92,7 @@ export function ChartGrid({
return;
}
onStrokeStart();
+ strokeActive.current = true;
drawing.current = tool !== "fill";
previousCell.current = `${cell.row}:${cell.column}`;
onPaint(cell.row, cell.column, tool === "fill");
@@ -115,9 +121,12 @@ export function ChartGrid({
}
function endPointerAction() {
+ const finish = strokeActive.current;
+ strokeActive.current = false;
drawing.current = false;
selecting.current = false;
selectionStart.current = null;
+ if (finish) onStrokeEnd?.();
}
const selectionBounds = selection
@@ -311,9 +320,11 @@ export function ChartGrid({
: tool === "fill"
? "Click an area to fill its yarn colour. Stitch symbols stay in place."
: tool === "select"
- ? "Drag a rectangle to fill, copy, cut, paste, move, or apply a stitch action."
+ ? yarnOnly
+ ? "Drag a rectangle to fill, copy, cut, paste or move yarn colours."
+ : "Drag a rectangle to fill, copy, cut, paste, move, or apply a stitch action."
: `Click and drag to ${tool === "eraser" ? "erase the yarn colour" : "paint"}${document.guides.symmetry !== "none" ? " with live symmetry" : ""}.`}
- {document.stitchActions && !structureAction
+ {document.stitchActions && !structureAction && !yarnOnly
? " Use Remove stitch to erase symbols without changing yarn colours."
: ""}
diff --git a/components/chart-maker.tsx b/components/chart-maker.tsx
index 4809c1a..f8a050c 100644
--- a/components/chart-maker.tsx
+++ b/components/chart-maker.tsx
@@ -1,14 +1,24 @@
"use client";
+import "@/app/yoke-designer/yoke.css";
import Link from "next/link";
+import { stringifyKnitplot } from "@/lib/yoke-program-wire";
import { useSyncedDraft } from "@/components/use-synced-draft";
import { ColourPalettePanel } from "@/components/colour-palette-panel";
import { StitchPalettePanel } from "@/components/stitch-palette-panel";
import { ChartMeasurementsPanel } from "@/components/chart-measurements-panel";
import { AccountAvatar } from "@/components/account-avatar";
-import { SetStateAction, useEffect, useRef, useState } from "react";
+import { SetStateAction, useEffect, useMemo, useRef, useState } from "react";
import { AiChartGenerator } from "@/components/ai-chart-generator";
import { ChartGrid } from "@/components/chart-grid";
+import { YokeEditor } from "@/components/yoke-editor";
+import { YokeKnittedPreview } from "@/components/yoke-knitted-preview";
+import { restoreYokeHistoryDocument } from "@/lib/yoke-history";
+import {
+ emptyStitchDraft,
+ type YokeStitchDraft,
+} from "@/components/use-yoke-stitches";
+import { YokePlanDialog } from "@/components/yoke-plan-dialog";
import { ExportTools } from "@/components/export-tools";
import { ImageImporter } from "@/components/image-importer";
import { InstructionsView } from "@/components/instructions-view";
@@ -74,6 +84,7 @@ import { remember, travelHistory, type TabHistory } from "@/lib/history";
import { useCloudSync } from "@/components/use-cloud-sync";
import { responseJson } from "@/lib/http";
import { tabSnapshot, workspaceSave } from "@/lib/workspace";
+import { samePreviewFabric } from "@/lib/preview-freshness";
import { useLocalWorkspace } from "@/components/use-local-workspace";
import { useCloudRecovery } from "@/components/use-cloud-recovery";
const colorDefaults = ["#d6a84b", "#6b7da8", "#884d67", "#5f795e", "#29282d"];
@@ -88,12 +99,14 @@ type ChartMakerProps = {
viewer: Viewer | null;
accountsEnabled: boolean;
aiConnected: boolean;
+ localTestChart?: ChartDocument;
};
export function ChartMaker({
viewer,
accountsEnabled,
aiConnected,
+ localTestChart,
}: ChartMakerProps) {
const [tabs, setTabs] = useState([
{
@@ -104,6 +117,30 @@ export function ChartMaker({
},
]);
const [activeTabId, setActiveTabId] = useState(INITIAL_TAB_ID);
+ const [yokeStitchDrafts, setYokeStitchDrafts] = useState<
+ Record
+ >({});
+ const hasPendingYokeStitches = tabs.some(
+ (tab) => yokeStitchDrafts[tab.id]?.edits.length,
+ );
+ function confirmLeavingEditor(event: { preventDefault(): void }) {
+ if (
+ hasPendingYokeStitches &&
+ !window.confirm(
+ "You have unapplied shaping changes. Leave without applying them?",
+ )
+ )
+ event.preventDefault();
+ }
+ useEffect(() => {
+ if (!hasPendingYokeStitches) return;
+ const warn = (event: BeforeUnloadEvent) => {
+ event.preventDefault();
+ event.returnValue = "";
+ };
+ window.addEventListener("beforeunload", warn);
+ return () => window.removeEventListener("beforeunload", warn);
+ }, [hasPendingYokeStitches]);
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
const document = activeTab.document;
const [tool, setTool] = useState("pencil");
@@ -119,9 +156,7 @@ export function ChartMaker({
});
const [previewDocuments, setPreviewDocuments] = useState<
Record
- >({
- [INITIAL_TAB_ID]: cloneDocument(defaultDocument),
- });
+ >(() => ({ [INITIAL_TAB_ID]: tabs[0].document }));
const preview = activeTab.preview;
const previewDocument = previewDocuments[activeTabId] ?? document;
const [chartZoom, setChartZoom] = useState(100);
@@ -129,6 +164,7 @@ export function ChartMaker({
const [selectionClipboard, setSelectionClipboard] =
useState(null);
const [knitModeOpen, setKnitModeOpen] = useState(false);
+ const [newYokeOpen, setNewYokeOpen] = useState(false);
const [patternImporterOpen, setPatternImporterOpen] = useState(false);
const [dimensionDraft, setDimensionDraft] = useSyncedDraft(
`${activeTabId}:${document.width}:${document.height}`,
@@ -183,7 +219,7 @@ export function ChartMaker({
setActiveTabId(next.activeTabId);
setPreviewDocuments((current) => ({
...current,
- [incoming.id]: cloneDocument(incoming.document),
+ [incoming.id]: incoming.document,
}));
if (next.activeTabId !== activeTabId) prepareTabView(incoming.document);
},
@@ -259,7 +295,7 @@ export function ChartMaker({
);
setPreviewDocuments(
Object.fromEntries(
- workspace.tabs.map((tab) => [tab.id, cloneDocument(tab.document)]),
+ workspace.tabs.map((tab) => [tab.id, tab.document]),
),
);
const active = workspace.tabs.find(
@@ -301,14 +337,14 @@ export function ChartMaker({
if (!past.length) return;
const next = travelHistory(history, document, "undo");
setHistories((current) => ({ ...current, [activeTabId]: next.history }));
- setDocument(next.document);
+ setDocument(restoreYokeHistoryDocument(document, next.document));
}
function redo() {
if (!future.length) return;
const next = travelHistory(history, document, "redo");
setHistories((current) => ({ ...current, [activeTabId]: next.history }));
- setDocument(next.document);
+ setDocument(restoreYokeHistoryDocument(document, next.document));
}
function paint(row: number, column: number, shouldFill: boolean) {
@@ -497,7 +533,8 @@ export function ChartMaker({
function refreshPreview() {
setPreviewDocuments((current) => ({
...current,
- [activeTabId]: cloneDocument(document),
+ // Give the renderer a fresh reference so the button can also retry a failure.
+ [activeTabId]: { ...document },
}));
updatePreviewSettings({
renderedRepeatX: preview.repeatX,
@@ -748,6 +785,11 @@ export function ChartMaker({
}
function openProject(nextDocument: ChartDocument) {
+ if (nextDocument.yoke || document.yoke) {
+ if (!addDocumentTab(cloneDocument(nextDocument)))
+ throw new Error("Close a chart tab before opening another project.");
+ return;
+ }
commit(() => cloneDocument(nextDocument));
setSelectedColor(nextDocument.palette[1]?.id ?? nextDocument.palette[0].id);
setTool("pencil");
@@ -777,10 +819,9 @@ export function ChartMaker({
prepareTabView(nextTab.document);
}
- function addTab() {
- if (tabs.length >= MAX_CHART_TABS) return;
+ function addDocumentTab(nextDocument: ChartDocument) {
+ if (tabs.length >= MAX_CHART_TABS) return false;
const id = `chart-${crypto.randomUUID()}`;
- const nextDocument = cloneDocument(defaultDocument);
setTabs((current) => [
...current,
{
@@ -792,20 +833,24 @@ export function ChartMaker({
]);
setPreviewDocuments((current) => ({
...current,
- [id]: cloneDocument(nextDocument),
+ [id]: nextDocument,
}));
setHistories((current) => ({ ...current, [id]: { past: [], future: [] } }));
setActiveTabId(id);
prepareTabView(nextDocument);
+ return true;
+ }
+ function addTab() {
+ addDocumentTab(cloneDocument(defaultDocument));
}
function duplicateTab() {
if (tabs.length >= MAX_CHART_TABS) return;
const id = `chart-${crypto.randomUUID()}`;
- const nextDocument = cloneDocument({
+ const nextDocument = {
...document,
name: `${document.name || "Untitled chart"} copy`,
- });
+ };
setTabs((current) => [
...current,
{
@@ -817,7 +862,7 @@ export function ChartMaker({
]);
setPreviewDocuments((current) => ({
...current,
- [id]: cloneDocument(current[activeTabId] ?? document),
+ [id]: current[activeTabId] ?? document,
}));
setHistories((current) => ({ ...current, [id]: { past: [], future: [] } }));
setActiveTabId(id);
@@ -846,7 +891,7 @@ export function ChartMaker({
]);
setPreviewDocuments((current) => ({
...current,
- [id]: cloneDocument(example.document),
+ [id]: example.document,
}));
setHistories((current) => ({ ...current, [id]: { past: [], future: [] } }));
setActiveTabId(id);
@@ -861,7 +906,7 @@ export function ChartMaker({
const response = await fetch("/api/charts", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
+ body: stringifyKnitplot({
document,
knitProgress: activeTab.knitProgress,
preview: activeTab.preview,
@@ -946,6 +991,11 @@ export function ChartMaker({
return;
}
setTabs((current) => current.filter((tab) => tab.id !== id));
+ setYokeStitchDrafts((current) => {
+ const next = { ...current };
+ delete next[id];
+ return next;
+ });
setHistories((current) => {
const next = { ...current };
delete next[id];
@@ -965,11 +1015,16 @@ export function ChartMaker({
setClosingTabId(null);
}
+ const previewMatchesChart = useMemo(
+ () => samePreviewFabric(document, previewDocument),
+ [document, previewDocument],
+ );
const previewOutdated =
- JSON.stringify(document) !== JSON.stringify(previewDocument) ||
- preview.repeatX !== preview.renderedRepeatX ||
- preview.repeatY !== preview.renderedRepeatY ||
- preview.repeatStyle !== preview.renderedRepeatStyle;
+ !previewMatchesChart ||
+ (!document.yoke &&
+ (preview.repeatX !== preview.renderedRepeatX ||
+ preview.repeatY !== preview.renderedRepeatY ||
+ preview.repeatStyle !== preview.renderedRepeatStyle));
const knitProgress =
tabs.find((tab) => tab.id === activeTabId)?.knitProgress ??
defaultKnitProgress;
@@ -1060,21 +1115,47 @@ export function ChartMaker({
+ {process.env.NODE_ENV === "development" && localTestChart ? (
+
= MAX_CHART_TABS &&
+ !tabs.some(
+ (tab) => tab.document.name === localTestChart.name,
+ ))
+ }
+ title="Opens as a separate tab. Close an unused chart tab if the workspace is full."
+ onClick={() => {
+ const existing = tabs.find(
+ (tab) => tab.document.name === localTestChart.name,
+ );
+ if (existing) switchTab(existing.id);
+ else openProject(localTestChart);
+ }}
+ >
+ Open Idun test chart
+
+ ) : null}
setKnitModeOpen(true)}
>
Knit mode
- {viewer && aiConnected ? (
-
+ {!document.yoke ? (
+ <>
+ {viewer && aiConnected ? (
+
+ ) : null}
+
+ >
) : null}
-
setPatternImporterOpen(true)}
+ onNewBlank={addTab}
+ onNewYoke={() => setNewYokeOpen(true)}
+ tabsFull={tabs.length >= MAX_CHART_TABS}
+ onNavigateAway={confirmLeavingEditor}
/>
= MAX_CHART_TABS}
@@ -1107,13 +1192,18 @@ export function ChartMaker({
>
) : (
-
+
Sign in
)}
@@ -1168,357 +1258,407 @@ export function ChartMaker({
-
-
-
-
-
Chart editor
-
-
-
{
- setTool("pencil");
- setStitchPickerOpen(false);
- }}
- >
- Draw
-
-
{
- setTool("fill");
- setStitchPickerOpen(false);
- }}
- >
- Fill
-
-
{
- setTool("eraser");
- setStructureAction(null);
- setStitchPickerOpen(false);
- }}
- >
- Erase
-
-
setTool("select")}
- >
- Select
-
- {selection && !structureAction ? (
-
- Fill selection
-
- ) : null}
- {selection && structureAction ? (
-
- Apply{" "}
- {
- stitchActionOptions.find(
- (option) => option.value === structureAction,
- )?.shortLabel
- }
-
- ) : null}
- {selection ? (
-
Copy
- ) : null}
- {selection ?
Cut : null}
- {selectionClipboard?.tabId === activeTabId ? (
-
- Paste
-
- ) : null}
- {selection ? (
-
- moveSelection(0, -1)}
- >
- ←
-
- moveSelection(-1, 0)}
+ {document.yoke ? (
+ <>
+
+ setYokeStitchDrafts((old) => ({ ...old, [activeTabId]: next }))
+ }
+ document={document}
+ onChange={setDocument}
+ onCreate={addDocumentTab}
+ onStrokeStart={rememberCurrent}
+ undo={undo}
+ redo={redo}
+ canUndo={past.length > 0}
+ canRedo={future.length > 0}
+ tabsFull={tabs.length >= MAX_CHART_TABS}
+ view={preview.yokeView ?? "chart"}
+ onViewChange={(yokeView) => updatePreviewSettings({ yokeView })}
+ >
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+
Chart editor
+
+
- ↑
-
- moveSelection(1, 0)}
+ {
+ setTool("pencil");
+ setStitchPickerOpen(false);
+ }}
+ >
+ Draw
+
+ {
+ setTool("fill");
+ setStitchPickerOpen(false);
+ }}
+ >
+ Fill
+
+ {
+ setTool("eraser");
+ setStructureAction(null);
+ setStitchPickerOpen(false);
+ }}
+ >
+ Erase
+
+ setTool("select")}
+ >
+ Select
+
+ {selection && !structureAction ? (
+
+ Fill selection
+
+ ) : null}
+ {selection && structureAction ? (
+
+ Apply{" "}
+ {
+ stitchActionOptions.find(
+ (option) => option.value === structureAction,
+ )?.shortLabel
+ }
+
+ ) : null}
+ {selection ? (
+ Copy
+ ) : null}
+ {selection ? (
+ Cut
+ ) : null}
+ {selectionClipboard?.tabId === activeTabId ? (
+
+ Paste
+
+ ) : null}
+ {selection ? (
+
+ moveSelection(0, -1)}
+ >
+ ←
+
+ moveSelection(-1, 0)}
+ >
+ ↑
+
+ moveSelection(1, 0)}
+ >
+ ↓
+
+ moveSelection(0, 1)}
+ >
+ →
+
+
+ ) : null}
+ {selection ? (
+ setSelection(null)}>
+ Deselect
+
+ ) : null}
+ Clear
+
+
+
- ↓
-
+ flipChart("horizontal")}
+ title={
+ selection
+ ? "Mirror selection left to right"
+ : "Mirror chart left to right"
+ }
+ >
+ ↔
+
+ flipChart("vertical")}
+ title={
+ selection
+ ? "Flip selection upside down"
+ : "Flip chart upside down"
+ }
+ >
+ ↕
+
+ MAX_CHART_ROWS ||
+ document.height > MAX_CHART_STITCHES
+ }
+ title={
+ selection
+ ? selectionRotationFits()
+ ? "Rotate selection 90° clockwise"
+ : "The rotated selection would extend beyond the chart"
+ : document.width > MAX_CHART_ROWS
+ ? `Rotation would exceed ${MAX_CHART_ROWS} rows`
+ : "Rotate chart 90° clockwise"
+ }
+ >
+ 90°
+
+
+
+
+
+ ↺
+
+
+ ↻
+
+
+
+
+ setChartZoom((value) => Math.max(100, value - 50))
+ }
+ disabled={chartZoom === 100}
+ >
+ −
+
+ {chartZoom}%
+
+ setChartZoom((value) => Math.min(400, value + 50))
+ }
+ disabled={chartZoom === 400}
+ >
+ +
+
+
+
+
+
+
+
+
+
+
+
Knitted up
+ approximate fabric at your gauge
+
+
+ Show / hide ⌄
+
+
+
+
+ Repeats
+
+ Style
+
+ updatePreviewSettings({
+ repeatStyle: event.target.value as RepeatStyle,
+ })
+ }
+ >
+ Normal
+ Mirrored
+
+
+
+ Across
+
+ updatePreviewSettings({
+ repeatX: Number(event.target.value),
+ })
+ }
+ >
+ 1
+ 2
+ 3
+
+
+
+ Down
+
+ updatePreviewSettings({
+ repeatY: Number(event.target.value),
+ })
+ }
+ >
+ 1
+ 2
+ 3
+
+
moveSelection(0, 1)}
+ className="primary-button refresh-preview-button"
+ onClick={refreshPreview}
>
- →
+ Refresh preview
-
- ) : null}
- {selection ? (
- setSelection(null)}>Deselect
- ) : null}
- Clear
-
-
-
- flipChart("horizontal")}
- title={
- selection
- ? "Mirror selection left to right"
- : "Mirror chart left to right"
- }
- >
- ↔
-
- flipChart("vertical")}
- title={
- selection
- ? "Flip selection upside down"
- : "Flip chart upside down"
- }
- >
- ↕
-
- MAX_CHART_ROWS ||
- document.height > MAX_CHART_STITCHES
- }
- title={
- selection
- ? selectionRotationFits()
- ? "Rotate selection 90° clockwise"
- : "The rotated selection would extend beyond the chart"
- : document.width > MAX_CHART_ROWS
- ? `Rotation would exceed ${MAX_CHART_ROWS} rows`
- : "Rotate chart 90° clockwise"
- }
- >
- 90°
-
-
-
-
-
- ↺
-
-
- ↻
-
-
-
-
- setChartZoom((value) => Math.max(100, value - 50))
- }
- disabled={chartZoom === 100}
- >
- −
-
- {chartZoom}%
-
- setChartZoom((value) => Math.min(400, value + 50))
- }
- disabled={chartZoom === 400}
- >
- +
-
-
+
+ {previewOutdated ? (
+
+ Preview needs a refresh.
+
+ ) : null}
+
+
+
-
-
-
-
-
-
-
-
Knitted up
- approximate fabric at your gauge
-
-
- Show / hide ⌄
-
-
-
-
- Repeats
-
- Style
-
- updatePreviewSettings({
- repeatStyle: event.target.value as RepeatStyle,
- })
- }
- >
- Normal
- Mirrored
-
-
-
- Across
-
- updatePreviewSettings({
- repeatX: Number(event.target.value),
- })
- }
- >
- 1
- 2
- 3
-
-
-
- Down
-
- updatePreviewSettings({
- repeatY: Number(event.target.value),
- })
- }
- >
- 1
- 2
- 3
-
-
-
- Refresh preview
-
-
- {previewOutdated ? (
-
- Preview needs a refresh.
-
- ) : null}
-
-
-
+ >
+ )}
+ {newYokeOpen ? (
+ = MAX_CHART_TABS}
+ onClose={() => setNewYokeOpen(false)}
+ onCommit={(next) => addDocumentTab(next)}
+ />
+ ) : null}
+
{!knitModeOpen ? (
@@ -1643,6 +1783,13 @@ export function ChartMaker({
” will be removed from this browser workspace. Save an editable
project first if you want to keep a separate copy.
+ {yokeStitchDrafts[closingTabId]?.edits.length ? (
+
+ This tab has pending shaping edits. Review them before closing
+ if you want to keep them; they are not included in saved files
+ yet.
+
+ ) : null}
(null),
ref = canvasRef ?? internalRef;
const [status, setStatus] = useState("Rendering…");
const [failed, setFailed] = useState(false);
+ const yokeFrames = useRef<{
+ document: ChartDocument;
+ frames: Map<
+ string,
+ { canvas: HTMLCanvasElement; width: number; height: number }
+ >;
+ } | null>(null);
const reflect =
repeatStyle === undefined ? mirrored : repeatStyle === "mirrored";
const down = Math.max(1, Math.min(3, Math.round(repeatY) || 1));
useEffect(() => {
+ function display(canvas: HTMLCanvasElement, width: number, height: number) {
+ const target = ref.current;
+ if (!target) return;
+ target.width = canvas.width;
+ target.height = canvas.height;
+ target.style.width = `${Math.min(width, 1400)}px`;
+ target.style.height = "auto";
+ target.style.aspectRatio = `${width}/${height}`;
+ const context = target.getContext("2d");
+ if (!context) throw new Error("Canvas is unavailable.");
+ // Rotate the actual output pixels so the PNG also has the neckline up.
+ if (yokeView && !isYokeSweaterView(yokeView))
+ context.setTransform(-1, 0, 0, -1, canvas.width, canvas.height);
+ context.drawImage(canvas, 0, 0);
+ context.resetTransform();
+ target.dataset.previewState = "ready";
+ }
+ const key = `${yokeView}:${yokeRepeat}:${stitchSize}:${repeatX}:${down}:${reflect}:${window.devicePixelRatio}`;
+ if (yokeView && yokeFrames.current?.document !== document)
+ yokeFrames.current = { document, frames: new Map() };
+ const frames = yokeView ? yokeFrames.current?.frames : undefined;
+ const cached = frames?.get(key);
+ if (cached && frames) {
+ frames.delete(key);
+ frames.set(key, cached);
+ display(cached.canvas, cached.width, cached.height);
+ setStatus("");
+ setFailed(false);
+ return;
+ }
const controller = new AbortController();
let cancelled = false;
- // eslint-disable-next-line react-hooks/set-state-in-effect -- Reset UI status when starting a cancellable worker/canvas render, not derived chart data.
setStatus("Rendering…");
setFailed(false);
if (ref.current) ref.current.dataset.previewState = "rendering";
@@ -50,6 +123,8 @@ export function ConnectedKnitPreview({
repeatX,
reflect,
controller.signal,
+ yokeView,
+ yokeRepeat,
);
if (cancelled || !ref.current) return;
const source = makeReliefSource(image);
@@ -76,11 +151,13 @@ export function ConnectedKnitPreview({
height = Math.ceil(
(fabric.height + (down - 1) * verticalPitch) * unit + bleed * 2,
);
+ // Keep both full-sweater sides in the same bounded preview cache.
+ const renderLimit = isYokeSweaterView(yokeView) ? 2400 : 3600;
const scale = Math.min(
2,
window.devicePixelRatio || 1,
- 3600 / width,
- 3600 / height,
+ renderLimit / width,
+ renderLimit / height,
);
// Paint a staging canvas, then commit atomically. Cancelling a refresh
// must not leave half a swatch in the visible preview or PNG export.
@@ -91,9 +168,13 @@ export function ConnectedKnitPreview({
ctx.scale(scale, scale);
ctx.fillStyle = "white";
ctx.fillRect(0, 0, width, height);
+ if (isYokeSweaterView(yokeView))
+ paintSweaterBacking(ctx, fabric, unit, bleed, rowHeight);
const palette = new Map(document.palette.map((c) => [c.id, c.hex]));
const sprites = new Map(),
masks = new Map();
+ const rowDistance = (a: ConnectedLoop, b: ConnectedLoop) =>
+ document.yoke ? Math.abs(a.packed - b.packed) : Math.abs(a.x - b.x);
let lastYield = performance.now();
for (let tile = down - 1; tile >= 0; tile--)
for (let r = fabric.rows.length - 1; r >= 0; r--) {
@@ -115,13 +196,14 @@ export function ConnectedKnitPreview({
lastYield = performance.now();
}
const hex =
+ node.yarn ??
palette.get(fabric.document.cells[r]?.[node.column]) ??
document.palette[0]?.hex ??
"#f2eadf";
const nearby = special.reduce(
(best, n) =>
- Math.abs(n.x - node.x) < 2.1 &&
- (!best || Math.abs(n.x - node.x) < Math.abs(best.x - node.x))
+ rowDistance(n, node) < 2.1 &&
+ (!best || rowDistance(n, node) < rowDistance(best, node))
? n
: best,
undefined,
@@ -143,24 +225,31 @@ export function ConnectedKnitPreview({
? 1.08
: 1;
const hScale =
- node.action === "cast-on"
+ (node.heightScale ?? 1) *
+ (node.action === "cast-on"
? 0.72
: node.action === "bind-off"
? 0.5
- : 1;
+ : 1);
const span = Math.round(node.span * widthScale * 16) / 16,
renderWidth = unit * span;
- const offsets = node.parents.map((id) => {
- const parent = fabric.loops[id];
- return (
- Math.round(
- (((parent.x - node.x) * Math.cos(node.angle) +
- (parent.y - node.y) * Math.sin(node.angle)) /
- span) *
- 16,
- ) / 16
+ const offsets = node.parents
+ .map((id) => {
+ const parent = fabric.loops[id];
+ return (
+ Math.round(
+ (((parent.x - node.x) * Math.cos(node.angle) +
+ (parent.y - node.y) * Math.sin(node.angle)) /
+ span) *
+ 16,
+ ) / 16
+ );
+ })
+ .map((offset) =>
+ document.yoke
+ ? Math.max(-2.5, Math.min(2.5, offset))
+ : offset,
);
- });
// Reflected vertical tiles are decorative pattern repeats, not
// another working sequence with reversed stitch ancestry.
const sources = verticalMirror
@@ -171,7 +260,7 @@ export function ConnectedKnitPreview({
// Sample the photo in knitting order, using actual loop positions
// rather than placeholder chart columns. Inserting an unworked
// chart cell must not introduce a new photographic fibre seam.
- const photoRow = document.height - 1 - r;
+ const photoRow = fabric.rows.length - 1 - r;
const col = node.packed % document.width,
tileCol = mirror ? document.width - 1 - col : col;
const sprite = makeStitchSprite(
@@ -187,11 +276,12 @@ export function ConnectedKnitPreview({
sources,
);
const verticalOffset =
- node.action === "cast-on"
+ (node.heightScale ?? 1) *
+ (node.action === "cast-on"
? rowHeight * 0.28
: node.action === "bind-off"
? rowHeight * 0.08
- : 0;
+ : 0);
const localY = node.y - fabric.minY;
ctx.save();
ctx.translate(
@@ -212,16 +302,20 @@ export function ConnectedKnitPreview({
}
}
if (cancelled || !ref.current) return;
- const target = ref.current;
- target.width = canvas.width;
- target.height = canvas.height;
- target.style.width = `${Math.min(width, 1400)}px`;
- target.style.height = "auto";
- target.style.aspectRatio = `${width}/${height}`;
- const targetContext = target.getContext("2d");
- if (!targetContext) throw new Error("Canvas is unavailable.");
- targetContext.drawImage(canvas, 0, 0);
- target.dataset.previewState = "ready";
+ display(canvas, width, height);
+ if (frames) {
+ // Bound backing-store memory even when browsing many repeats.
+ frames.set(key, { canvas, width, height });
+ let pixels = [...frames.values()].reduce(
+ (sum, frame) => sum + frame.canvas.width * frame.canvas.height,
+ 0,
+ );
+ for (const [oldKey, frame] of frames) {
+ if (frames.size <= 5 && pixels <= 12_000_000) break;
+ frames.delete(oldKey);
+ pixels -= frame.canvas.width * frame.canvas.height;
+ }
+ }
setStatus(
[
fabric.unresolvedRows.length
@@ -262,7 +356,7 @@ export function ConnectedKnitPreview({
image.onload = null;
image.onerror = null;
};
- }, [document, repeatX, down, reflect, stitchSize, ref]);
+ }, [document, repeatX, down, reflect, stitchSize, ref, yokeView, yokeRepeat]);
return (
Promise
;
onImportPattern: () => void;
+ onNewBlank?: () => void;
+ onNewYoke?: () => void;
+ tabsFull?: boolean;
+ onNavigateAway?: (event: { preventDefault(): void }) => void;
};
type ProjectFile = {
@@ -202,6 +207,10 @@ export function ExportTools({
cloudSaveMessage,
onSaveToCloud,
onImportPattern,
+ onNewBlank,
+ onNewYoke,
+ tabsFull,
+ onNavigateAway,
}: Props) {
const inputRef = useRef(null);
const menuRef = useRef(null);
@@ -237,7 +246,7 @@ export function ExportTools({
document: cloneDocument(document),
};
downloadBlob(
- new Blob([JSON.stringify(project, null, 2)], {
+ new Blob([stringifyKnitplot(project, 2)], {
type: "application/json",
}),
fileName(document.name, "knitplot"),
@@ -289,7 +298,11 @@ export function ExportTools({
!canvas.height ||
canvas.dataset.previewState === "rendering"
) {
- setMessage("The knitted preview is still loading.");
+ setMessage(
+ !canvas && document.yoke
+ ? "Choose an available view in Knitted up before downloading the preview."
+ : "The knitted preview is still loading.",
+ );
return;
}
if (canvas.dataset.previewState === "error") {
@@ -336,7 +349,7 @@ export function ExportTools({
ref={inputRef}
className="visually-hidden"
type="file"
- accept=".knitplot,.colorwork.json,application/json"
+ accept=".knitplot,.knitplot-yoke,.colorwork.json,application/json"
onChange={openProject}
/>
+ New
+ {
+ setOpen(false);
+ onNewBlank?.();
+ }}
+ >
+ Blank chart
+
+ {
+ setOpen(false);
+ onNewYoke?.();
+ }}
+ >
+ Circular yoke
+
+
On this device
{
setOpen(false);
onImportPattern();
@@ -405,12 +441,20 @@ export function ExportTools({
: "Save to My Charts"}
)}
-
+
View My Charts
>
) : (
-
+
Sign in to use My Charts
)}
@@ -423,7 +467,9 @@ export function ExportTools({
aria-expanded={choosingChartImage}
onClick={() => setChoosingChartImage((value) => !value)}
>
- Download chart image
+ {document.yoke
+ ? "Download repeat chart image"
+ : "Download chart image"}
{choosingChartImage ? (
writtenRows(instructionDocument, settings),
- [instructionDocument, settings],
+ () =>
+ open && !document.yoke ? writtenRows(instructionDocument, settings) : [],
+ [document.yoke, instructionDocument, open, settings],
);
const size = useMemo(
() => finishedSize(instructionDocument),
@@ -426,256 +428,262 @@ export function InstructionsView({ document, onSettingsChange }: Props) {
-
-
- Technique
-
- updateSetting(
- "technique",
- event.target.value as InstructionSettings["technique"],
- )
- }
- >
- Stranded colourwork
- Intarsia
- Duplicate stitch
-
-
- {!isDuplicate ? (
+ {!document.yoke ? (
+
- Construction
+ Technique
updateSetting(
- "construction",
- event.target
- .value as InstructionSettings["construction"],
+ "technique",
+ event.target.value as InstructionSettings["technique"],
)
}
>
- Worked flat
- In the round
+ Stranded colourwork
+ Intarsia
+ Duplicate stitch
- ) : null}
-
- Pattern use
-
- updateSetting(
- "patternUse",
- event.target.value as InstructionSettings["patternUse"],
- )
- }
- >
- Single motif
- Repeating pattern
-
-
-
-
- updateSetting("trimBackground", event.target.checked)
- }
- />
-
- Trim empty background
-
- Crop the instruction chart to the smallest rectangle
- containing the colourwork.
-
-
-
-
-
- updateSetting("showSymbols", event.target.checked)
- }
- />
-
- Show colour symbols
-
- Add a distinct symbol to every chart cell and the colour key
- for easier grayscale printing.
-
-
-
- {settings.trimBackground && !region.hasDesign ? (
-
- Add at least one non-background stitch before trimming.
-
- ) : null}
- {settings.trimBackground &&
- region.hasDesign &&
- !region.trimmed ? (
-
- The design already reaches every outer edge, so there is no
- empty border to trim.
-
- ) : null}
- {settings.trimBackground &&
- region.hasDesign &&
- settings.patternUse === "repeat" ? (
-
- The trimmed width becomes the new repeat width, so any removed
- background spacing will not appear between repeats.
-
- ) : null}
-
-
-
-
+ {settings.trimBackground &&
+ region.hasDesign &&
+ settings.patternUse === "repeat" ? (
+
+ The trimmed width becomes the new repeat width, so any
+ removed background spacing will not appear between repeats.
+
+ ) : null}
+
+ ) : null}
-
+ {document.yoke ? (
+
+ ) : (
+
+
+ Colorwork knitting chart
+ {document.name || "Untitled chart"}
+
+ {instructionDocument.width} stitches ×{" "}
+ {instructionDocument.height} rows · Approximately{" "}
+ {size.width.toFixed(1)} × {size.height.toFixed(1)}{" "}
+ {document.gaugeUnit}
+
+ {region.trimmed ? (
+
+ Trimmed from the original {document.width} ×{" "}
+ {document.height} chart. The motif begins{" "}
+ {region.leftOffset}{" "}
+ {region.leftOffset === 1 ? "stitch" : "stitches"} from the
+ left and {region.bottomOffset}{" "}
+ {region.bottomOffset === 1 ? "row" : "rows"} from the
+ bottom.
+
+ ) : null}
+
-
-
- Technique
-
- {settings.technique === "stranded"
- ? "Stranded colourwork"
- : settings.technique === "intarsia"
- ? "Intarsia"
- : "Duplicate stitch"}
-
-
- {!isDuplicate ? (
+
+
+
- Construction
+ Technique
- {settings.construction === "flat"
- ? "Worked flat"
- : "In the round"}
+ {settings.technique === "stranded"
+ ? "Stranded colourwork"
+ : settings.technique === "intarsia"
+ ? "Intarsia"
+ : "Duplicate stitch"}
- ) : null}
-
- Pattern
-
- {settings.patternUse === "single"
- ? "Single motif"
- : `${instructionDocument.width}-stitch repeat`}
-
-
-
- Gauge
-
- {document.gaugeStitches} sts and {document.gaugeRows} rows
- over {document.gaugeMeasure} {document.gaugeUnit}
-
-
-
-
-
- Colour key
-
- {document.palette.map((color, index) => (
-
-
- {settings.showSymbols ? (
-
- {PRINT_SYMBOLS[index] ?? "?"}
-
- ) : null}
-
{index === 0 ? "MC" : `CC${index}`}
+ {!isDuplicate ? (
+
+ Construction
- {index === 0
- ? "Main/background colour"
- : `Contrast colour ${index}`}
+ {settings.construction === "flat"
+ ? "Worked flat"
+ : "In the round"}
- {color.hex.toUpperCase()}
- ))}
-
-
+ ) : null}
+
+ Pattern
+
+ {settings.patternUse === "single"
+ ? "Single motif"
+ : `${instructionDocument.width}-stitch repeat`}
+
+
+
+ Gauge
+
+ {document.gaugeStitches} sts and {document.gaugeRows} rows
+ over {document.gaugeMeasure} {document.gaugeUnit}
+
+
+
+
+
+ Colour key
+
+ {document.palette.map((color, index) => (
+
+
+ {settings.showSymbols ? (
+
+ {PRINT_SYMBOLS[index] ?? "?"}
+
+ ) : null}
+ {index === 0 ? "MC" : `CC${index}`}
+
+ {index === 0
+ ? "Main/background colour"
+ : `Contrast colour ${index}`}
+
+ {color.hex.toUpperCase()}
+
+ ))}
+
+
-
- How to work the chart
- {techniqueInstructions(settings)}
-
- Begin with {unitName.toLowerCase()} 1 at the bottom of the
- chart.{" "}
- {isDuplicate
- ? "Follow each chart row from left to right, or embroider the duplicate stitches in any order that feels comfortable."
- : settings.construction === "round"
- ? "Read every round from right to left."
- : "Read right-side rows from right to left and wrong-side rows from left to right."}
- {settings.patternUse === "repeat"
- ? ` Repeat the full ${instructionDocument.width}-stitch chart across the work as needed.`
- : " Work the chart once, positioning the motif as required in your project."}
-
-
+
+ How to work the chart
+ {techniqueInstructions(settings)}
+
+ Begin with {unitName.toLowerCase()} 1 at the bottom of the
+ chart.{" "}
+ {isDuplicate
+ ? "Follow each chart row from left to right, or embroider the duplicate stitches in any order that feels comfortable."
+ : settings.construction === "round"
+ ? "Read every round from right to left."
+ : "Read right-side rows from right to left and wrong-side rows from left to right."}
+ {settings.patternUse === "repeat"
+ ? ` Repeat the full ${instructionDocument.width}-stitch chart across the work as needed.`
+ : " Work the chart once, positioning the motif as required in your project."}
+
+
-
-
- Written{" "}
- {isDuplicate
- ? "rows"
- : settings.construction === "round"
- ? "rounds"
- : "rows"}
-
-
- {settings.technique === "duplicate"
- ? "Sequences are written in chart-reading order. Skip counts are base stitches left unembroidered; other counts are duplicate stitches worked in that colour."
- : showStitchMath
- ? "Sequences are written in working order. Colour counts show ordinary knit stitches; special stitch actions show their own abbreviations. The count beneath each row accounts for stitches consumed and produced by those actions."
- : "Sequences are written in working order. Each number is the number of stitches to work in that colour."}
-
-
- {rows.map((row) => (
-
-
- {unitName} {row.number}
- {row.side ? ` (${row.side})` : ""}:
- {" "}
- {row.sequence}
- {showStitchMath ? (
-
- {row.startingStitches} sts in → {row.endingStitches}{" "}
- sts out (
- {row.change > 0 ? `+${row.change}` : row.change})
-
- ) : null}
-
- ))}
-
-
-
+
+
+ Written{" "}
+ {isDuplicate
+ ? "rows"
+ : settings.construction === "round"
+ ? "rounds"
+ : "rows"}
+
+
+ {settings.technique === "duplicate"
+ ? "Sequences are written in chart-reading order. Skip counts are base stitches left unembroidered; other counts are duplicate stitches worked in that colour."
+ : showStitchMath
+ ? "Sequences are written in working order. Colour counts show ordinary knit stitches; special stitch actions show their own abbreviations. The count beneath each row accounts for stitches consumed and produced by those actions."
+ : "Sequences are written in working order. Each number is the number of stitches to work in that colour."}
+
+
+ {rows.map((row) => (
+
+
+ {unitName} {row.number}
+ {row.side ? ` (${row.side})` : ""}:
+ {" "}
+ {row.sequence}
+ {showStitchMath ? (
+
+ {row.startingStitches} sts in → {row.endingStitches}{" "}
+ sts out (
+ {row.change > 0 ? `+${row.change}` : row.change})
+
+ ) : null}
+
+ ))}
+
+
+
+ )}
setOpen(false)}>Close
diff --git a/components/knit-mode.tsx b/components/knit-mode.tsx
index 561ec2e..f1b790e 100644
--- a/components/knit-mode.tsx
+++ b/components/knit-mode.tsx
@@ -16,19 +16,21 @@ import {
rowStitchMath,
rowStitchSteps,
stitchActionAt,
- stitchActionLabel,
stitchActionMath,
stitchActionSymbol,
chartCellDisplayColor,
copyStitchCellColors,
} from "@/lib/chart";
-
-export type KnitProgress = {
- row: number;
- stitch: number;
- trimBorder: boolean;
- markerStyle: MarkerStyle;
-};
+import type { KnitProgress } from "@/lib/chart-schema";
+import { rowSequence, workedStepLabel } from "@/lib/knit-sequence";
+import {
+ moveYokeKnitStep,
+ moveYokeKnitPage,
+ yokeKnitModel,
+ yokeKnitPages,
+} from "@/lib/yoke-knit";
+import { planYokeShortRows, shortRowInstructions } from "@/lib/yoke-short-rows";
+export type { KnitProgress } from "@/lib/chart-schema";
export type MarkerStyle = "none" | "flower" | "heart" | "star" | "yarn";
@@ -59,8 +61,8 @@ function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value));
}
-function majorColumnBoundary(column: number, width: number) {
- return column > 0 && column < width && column % 10 === 0;
+function majorColumnBoundary(column: number, width: number, offset = 0) {
+ return column > 0 && column < width && (column + offset) % 10 === 0;
}
function majorRowBoundary(chartRow: number, height: number) {
@@ -73,57 +75,6 @@ function isRightToLeft(document: ChartDocument, row: number) {
return document.instructions.construction === "round" || row % 2 === 1;
}
-function rowSequence(document: ChartDocument, rowNumber: number) {
- const source = rowStitchSteps(document, rowNumber);
- const ordered = isRightToLeft(document, rowNumber)
- ? [...source].reverse()
- : source;
- const labels = new Map(
- document.palette.map((color, index) => [
- color.id,
- index === 0 ? "MC" : `CC${index}`,
- ]),
- );
- if (document.instructions.technique === "duplicate")
- labels.set(document.palette[0].id, "Skip");
- return ordered.reduce<
- Array<{
- key: string;
- colorId: string;
- label: string;
- count: number;
- showCount: boolean;
- }>
- >((runs, step) => {
- if (step.action !== "knit") {
- const isEdgeAction =
- step.action === "cast-on" || step.action === "bind-off";
- const previous = runs[runs.length - 1];
- if (isEdgeAction && previous?.key === step.action) previous.count += 1;
- else
- runs.push({
- key: isEdgeAction ? step.action : `${step.action}-${step.column}`,
- colorId: step.colorId,
- label: stitchActionLabel(step.action),
- count: 1,
- showCount: isEdgeAction,
- });
- return runs;
- }
- const previous = runs[runs.length - 1];
- if (previous?.key === step.colorId) previous.count += 1;
- else
- runs.push({
- key: step.colorId,
- colorId: step.colorId,
- label: labels.get(step.colorId) ?? "?",
- count: 1,
- showCount: true,
- });
- return runs;
- }, []);
-}
-
function knitRegion(document: ChartDocument, shouldTrim: boolean) {
if (!shouldTrim) return { document, trimmed: false };
const backgroundId = document.palette[0]?.id;
@@ -188,35 +139,119 @@ export function KnitMode({
() => false,
);
const wakeLockRef = useRef
(null);
+ const yoke = document.yoke;
+ const model = useMemo(() => (yoke ? yokeKnitModel(yoke) : null), [yoke]);
+ const sectionColumns = progress.sectionColumns ?? 100;
+ const pages = useMemo(
+ () => (yoke && model ? yokeKnitPages(yoke, model, sectionColumns) : null),
+ [yoke, model, sectionColumns],
+ );
+ const pageRounds = useMemo(
+ () =>
+ model && pages
+ ? model.rounds.map((round, r) => ({
+ ...round,
+ lengths: pages.map((page) => page.rows[r].steps.length),
+ }))
+ : null,
+ [model, pages],
+ );
+ const roundNumber = clamp(progress.row, 1, document.height);
+ const markerSourceSection = model?.identical ? 0 : (progress.section ?? 0);
+ const markerPage =
+ pages?.findIndex((page) =>
+ page.rows[roundNumber - 1].steps.some(
+ (step) =>
+ step.sourceSection === markerSourceSection &&
+ step.sourceStep === progress.stitch,
+ ),
+ ) ?? -1;
+ const requestedPage = clamp(
+ progress.viewSection ?? Math.max(0, markerPage),
+ 0,
+ (pages?.length ?? 1) - 1,
+ );
+ const section =
+ pages && !pages[requestedPage].rows[roundNumber - 1].steps.length
+ ? pages.findIndex(
+ (page, index) =>
+ index > requestedPage && page.rows[roundNumber - 1].steps.length,
+ ) >= 0
+ ? pages.findIndex(
+ (page, index) =>
+ index > requestedPage && page.rows[roundNumber - 1].steps.length,
+ )
+ : Math.max(
+ 0,
+ pages.findIndex((page) => page.rows[roundNumber - 1].steps.length),
+ )
+ : requestedPage;
+ const sectionChart = pages?.[section] ?? null;
+ const shortRows = useMemo(() => {
+ if (!yoke?.measurements.backRise) return null;
+ try {
+ const plan = planYokeShortRows(yoke.measurements, yoke.shortRows);
+ return plan.extraRows ? shortRowInstructions(plan) : null;
+ } catch {
+ return [
+ "Review the back-neck turns in Edit shaping before starting Round 1.",
+ ];
+ }
+ }, [yoke]);
const region = useMemo(
- () => knitRegion(document, progress.trimBorder),
+ () => knitRegion(document, !document.yoke && progress.trimBorder),
[document, progress.trimBorder],
);
- const activeDocument = region.document;
+ const activeDocument = sectionChart?.document ?? region.document;
const aspect = cellAspectRatio(activeDocument);
const palette = new Map(
activeDocument.palette.map((color) => [color.id, color.hex]),
);
const row = clamp(progress.row, 1, activeDocument.height);
- const structural = hasStructuralStitches(activeDocument);
+ const structural = Boolean(yoke) || hasStructuralStitches(activeDocument);
const rowSteps = rowStitchSteps(activeDocument, row);
- const rowMath = rowStitchMath(activeDocument, row);
- const orderedRowSteps = isRightToLeft(activeDocument, row)
- ? [...rowSteps].reverse()
- : rowSteps;
- const stitch = clamp(progress.stitch, 0, orderedRowSteps.length);
+ const rowMath = model?.rounds[row - 1] ?? rowStitchMath(activeDocument, row);
+ const rightToLeft = !yoke && isRightToLeft(activeDocument, row);
+ const orderedRowSteps =
+ sectionChart?.rows[row - 1].steps ??
+ (rightToLeft ? [...rowSteps].reverse() : rowSteps);
+ const stitch = sectionChart
+ ? sectionChart.rows[row - 1].steps.findIndex(
+ (step) =>
+ step.sourceSection === markerSourceSection &&
+ step.sourceStep === progress.stitch,
+ ) + 1
+ : clamp(progress.stitch, 0, orderedRowSteps.length);
const activeStep = stitch > 0 ? orderedRowSteps[stitch - 1] : undefined;
+ const activeStepLabel = activeStep
+ ? workedStepLabel(activeDocument, activeStep, Boolean(yoke))
+ : "";
const activeStepMath = activeStep
? stitchActionMath(activeStep.action)
: undefined;
const rowIndex = activeDocument.height - row;
- const rightToLeft = isRightToLeft(activeDocument, row);
const markerColumn =
stitch > 0 ? (orderedRowSteps[stitch - 1]?.column ?? -1) : -1;
const markerX = (markerColumn + 0.5) * aspect;
const marker =
MARKERS.find((choice) => choice.id === progress.markerStyle) ?? MARKERS[1];
- const sequence = rowSequence(activeDocument, row);
+ const sequence = rowSequence(activeDocument, orderedRowSteps, Boolean(yoke));
+ const position = { row, section, stitch };
+ const nextYokeStep =
+ pageRounds && pages
+ ? moveYokeKnitStep(pageRounds, pages.length, position, 1)
+ : null;
+ const previousYokeStep =
+ pageRounds && pages
+ ? moveYokeKnitStep(pageRounds, pages.length, position, -1)
+ : null;
+ const pageLengths = pageRounds?.map((round) => round.lengths);
+ const nextPage = pageLengths
+ ? moveYokeKnitPage(pageLengths, row, section, 1)
+ : null;
+ const previousPage = pageLengths
+ ? moveYokeKnitPage(pageLengths, row, section, -1)
+ : null;
const isDuplicate = activeDocument.instructions.technique === "duplicate";
const unitName = isDuplicate
? "Row"
@@ -280,21 +315,60 @@ export function KnitMode({
};
}, [keepScreenAwake, wakeLockSupported]);
- function update(nextRow: number, nextStitch: number) {
+ function update(nextRow: number, nextStitch: number, nextSection = section) {
const clampedRow = clamp(nextRow, 1, activeDocument.height);
- const maximumStep = rowStitchSteps(activeDocument, clampedRow).length;
+ const maximumStep =
+ pageRounds?.[clampedRow - 1].lengths[nextSection] ??
+ rowStitchSteps(activeDocument, clampedRow).length;
+ if (pages) {
+ const page = pages[nextSection];
+ const local = clamp(nextStitch, 0, maximumStep);
+ const step = local
+ ? page.rows[clampedRow - 1].steps[local - 1]
+ : undefined;
+ onProgressChange({
+ ...progress,
+ shortRowNoteSeen: true,
+ row: clampedRow,
+ viewSection: nextSection,
+ section: step?.sourceSection ?? 0,
+ stitch: step?.sourceStep ?? 0,
+ });
+ return;
+ }
onProgressChange({
...progress,
row: clampedRow,
stitch: clamp(nextStitch, 0, maximumStep),
+ ...(yoke ? { section: nextSection } : {}),
});
}
function changeRow(amount: number) {
- update(row + amount, 0);
+ const next = amount > 0 ? nextPage : previousPage;
+ if (next) {
+ navigate(next.row, next.section);
+ return;
+ }
+ update(row + amount, 0, 0);
+ }
+
+ function navigate(nextRow: number, nextSection: number) {
+ onProgressChange({
+ ...progress,
+ ...(yoke ? { shortRowNoteSeen: true } : {}),
+ row: nextRow,
+ viewSection: nextSection,
+ ...(nextRow !== row ? { section: 0, stitch: 0 } : {}),
+ });
}
function changeStitch(amount: number) {
+ const yokeStep = amount > 0 ? nextYokeStep : previousYokeStep;
+ if (yokeStep) {
+ update(yokeStep.row, yokeStep.stitch, yokeStep.section);
+ return;
+ }
const next = stitch + amount;
if (next > orderedRowSteps.length && row < activeDocument.height)
update(row + 1, 0);
@@ -306,6 +380,14 @@ export function KnitMode({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
+ else if (
+ event.target instanceof HTMLElement &&
+ (event.target.closest(
+ "input, select, textarea, summary, [contenteditable=true]",
+ ) ||
+ (event.key === " " && event.target.closest("button")))
+ )
+ return;
else if (event.key === "ArrowRight" || event.key === " ") changeStitch(1);
else if (event.key === "ArrowLeft") changeStitch(-1);
else if (event.key === "ArrowUp") changeRow(1);
@@ -317,6 +399,114 @@ export function KnitMode({
return () => window.removeEventListener("keydown", handleKeyDown);
});
+ const progressionControls = (
+
+ changeRow(-1)}
+ disabled={
+ previousPage
+ ? previousPage.row === row && previousPage.section === section
+ : row === 1
+ }
+ >
+ Previous{" "}
+ {previousPage?.row === row && previousPage.section !== section
+ ? "section"
+ : yoke
+ ? "round"
+ : "row"}
+
+ changeRow(1)}
+ disabled={
+ nextPage
+ ? nextPage.row === row && nextPage.section === section
+ : row === activeDocument.height
+ }
+ >
+ Next{" "}
+ {nextPage?.row === row && nextPage.section !== section
+ ? "section"
+ : yoke
+ ? "round"
+ : "row"}
+
+
+ );
+ const sectionSizeControl =
+ model && pages ? (
+
+ Max columns per section
+ {
+ const columns = Number(event.target.value);
+ const next = yokeKnitPages(yoke!, model, columns);
+ const anchor = pages[section];
+ const viewSection = next.findIndex(
+ (page) => page.start <= anchor.start && page.end > anchor.start,
+ );
+ onProgressChange({
+ ...progress,
+ sectionColumns: columns,
+ viewSection: Math.max(0, viewSection),
+ });
+ }}
+ >
+ {[50, 75, 100, 150, 200].map((value) => (
+
+ {value}
+
+ ))}
+
+
+ ) : null;
+ const jumpControl =
+ model && pages ? (
+
+ Jump to…
+
+
+ Round
+ {
+ const target = Number(event.target.value);
+ const first = pages.findIndex(
+ (page) => page.rows[target - 1].steps.length,
+ );
+ navigate(target, Math.max(0, first));
+ }}
+ >
+ {Array.from({ length: document.height }, (_, i) => (
+
+ {i + 1}
+
+ ))}
+
+
+
+ Section
+ navigate(row, Number(event.target.value))}
+ >
+ {pages.map((page, i) => (
+
+ {i + 1} / {pages.length}
+
+ ))}
+
+
+
+
+ ) : null;
+
return (
{unitName} {row} of {activeDocument.height}
+ {pages && pages.length > 1 ? (
+
+ Section {section + 1} of {pages.length}
+
+ ) : null}
+ {markerPage >= 0 && markerPage !== section ? (
+ Marker saved in section {markerPage + 1}
+ ) : null}
{stitch > 0
? progress.markerStyle === "none"
@@ -388,6 +586,14 @@ export function KnitMode({
onClick={() => {
if (action === "no-stitch") return;
const nextRow = activeDocument.height - chartRowIndex;
+ if (sectionChart) {
+ const step =
+ sectionChart.rows[nextRow - 1].atColumn.get(
+ columnIndex,
+ );
+ if (step !== undefined) update(nextRow, step);
+ return;
+ }
const steps = rowStitchSteps(activeDocument, nextRow);
const orderedSteps = isRightToLeft(
activeDocument,
@@ -468,7 +674,11 @@ export function KnitMode({
{Array.from({ length: activeDocument.width }, (_, columnIndex) =>
- (columnIndex + 1) % columnStep === 0 ? (
+ (columnIndex + 1 + (sectionChart?.start ?? 0)) % columnStep ===
+ 0 ? (
- {columnIndex + 1}
+ {columnIndex + 1 + (sectionChart?.start ?? 0)}
) : null,
)}
@@ -569,61 +780,91 @@ export function KnitMode({
) : null}
+ {sectionSizeControl}
+ {sectionChart ? (
+
+
+ Columns {sectionChart.start + 1}–{sectionChart.end}
+
+ {jumpControl}
+
+ ) : null}
-
-
- Technique
-
- onSettingsChange({
- ...document.instructions,
- technique: event.target
- .value as InstructionSettings["technique"],
- })
- }
+ {model?.identical ? (
+
+ Work{" "}
+ {pages && pages.length > 1
+ ? "all sections of this repeat"
+ : "this repeat"}{" "}
+ {model.program.startingCounts.length} times per round.
+
+ ) : null}
+ {model && pages ? (
+ <>
+ {markerPage >= 0 && markerPage !== section ? (
+ navigate(row, markerPage)}>
+ Go to marker
+
+ ) : null}
+ >
+ ) : (
+ <>
+
- Stranded colourwork
- Intarsia
- Duplicate stitch
-
-
- {!isDuplicate ? (
-
- Construction
-
+ Technique
+
+ onSettingsChange({
+ ...document.instructions,
+ technique: event.target
+ .value as InstructionSettings["technique"],
+ })
+ }
+ >
+ Stranded colourwork
+ Intarsia
+ Duplicate stitch
+
+
+ {!isDuplicate ? (
+
+ Construction
+
+ onSettingsChange({
+ ...document.instructions,
+ construction: event.target
+ .value as InstructionSettings["construction"],
+ })
+ }
+ >
+ Worked flat
+ In the round
+
+
+ ) : null}
+
+
+
- onSettingsChange({
- ...document.instructions,
- construction: event.target
- .value as InstructionSettings["construction"],
+ onProgressChange({
+ ...progress,
+ trimBorder: event.target.checked,
+ row: 1,
+ stitch: 0,
})
}
- >
- Worked flat
- In the round
-
+ />{" "}
+ Trim empty border
- ) : null}
-
-
-
- onProgressChange({
- ...progress,
- trimBorder: event.target.checked,
- row: 1,
- stitch: 0,
- })
- }
- />{" "}
- Trim empty border
-
+ >
+ )}
Marker
{MARKERS.map((choice) => (
@@ -642,14 +883,16 @@ export function KnitMode({
))}
- {progress.trimBorder && !region.trimmed ? (
+ {!yoke && progress.trimBorder && !region.trimmed ? (
There is no empty outer border to trim.
) : null}
- {isDuplicate
- ? "Follow each chart row from left to right, or work the duplicate stitches in any order that feels comfortable."
- : `Work this ${activeDocument.instructions.construction === "round" ? "round" : "row"} ${rightToLeft ? "right to left" : "left to right"}.`}{" "}
+ {yoke
+ ? "Start at centre back and follow the chart left to right, in section order."
+ : isDuplicate
+ ? "Follow each chart row from left to right, or work the duplicate stitches in any order that feels comfortable."
+ : `Work this ${activeDocument.instructions.construction === "round" ? "round" : "row"} ${rightToLeft ? "right to left" : "left to right"}.`}{" "}
{structural
? "Skip no-stitch cells and work special stitch actions as shown."
: "Click any stitch to place your marker there."}
@@ -689,9 +932,10 @@ export function KnitMode({
>
Current {structural ? "step" : "stitch"}
- {activeStep.action === "knit"
- ? `Work ${activeDocument.palette.findIndex((color) => color.id === activeStep.colorId) === 0 ? "MC" : `CC${activeDocument.palette.findIndex((color) => color.id === activeStep.colorId)}`}`
- : stitchActionLabel(activeStep.action)}
+ {activeStep.action === "knit" && activeStepLabel !== "Skip"
+ ? "Work "
+ : ""}
+ {activeStepLabel}
{activeStep.action === "purl" ? (
Work one purl stitch as marked.
@@ -699,64 +943,79 @@ export function KnitMode({
{activeStepMath.consumes} stitch
{activeStepMath.consumes === 1 ? "" : "es"} in →{" "}
- {activeStepMath.produces} out. This is not an MC stitch.
+ {activeStepMath.produces} out.
+ {yoke ? "" : " This is not an MC stitch."}
) : null}
) : null}
+ {shortRows &&
+ !progress.shortRowNoteSeen &&
+ row === 1 &&
+ section === 0 &&
+ progress.stitch === 0 ? (
+
+
First, work the back-neck short rows.
+
+ Written steps
+ {shortRows.map((line) => (
+ {line}
+ ))}
+
+
+ ) : null}
{sequence.map((run, index) => (
- {run.key === run.colorId ? (
+ {run.action === "knit" ? (
) : null}
{run.label} {run.showCount ? run.count : ""}
))}
-
- changeRow(-1)} disabled={row === 1}>
- Previous row
-
- changeRow(1)}
- disabled={row === activeDocument.height}
- >
- Next row
-
-
+ {progressionControls}
changeStitch(-1)}
- disabled={row === 1 && stitch === 0}
+ disabled={
+ model
+ ? previousYokeStep === position
+ : row === 1 && stitch === 0
+ }
>
Previous {structural ? "step" : "stitch"}
changeStitch(1)}
disabled={
- row === activeDocument.height &&
- stitch === orderedRowSteps.length
+ model
+ ? nextYokeStep === position
+ : row === activeDocument.height &&
+ stitch === orderedRowSteps.length
}
>
Next {structural ? "step" : "stitch"}
- update(1, 0)}>
- Start again from row 1
+ update(1, 0, 0)}>
+ Start again from {yoke ? "round" : "row"} 1
Use the left and right arrow keys for{" "}
{structural ? "steps" : "stitches"}, the up and down arrow keys for
- rows, or press Space for the next {structural ? "step" : "stitch"}.
- Your place is remembered for this chart.
+ {yoke ? "sections and rounds" : "rows"}, or press Space for the next{" "}
+ {structural ? "step" : "stitch"}. Your place is remembered for this
+ chart.
diff --git a/components/my-charts-library.tsx b/components/my-charts-library.tsx
index ccfe928..d64970d 100644
--- a/components/my-charts-library.tsx
+++ b/components/my-charts-library.tsx
@@ -231,6 +231,7 @@ export function MyChartsLibrary({
{chart.name}
+ {chart.kind === "yoke" ? "Yoke · " : ""}
{chart.width} × {chart.height} st
@@ -427,6 +428,7 @@ export function MyChartsLibrary({
{chart.name}
+ {chart.kind === "yoke" ? "Yoke · " : ""}
{chart.width} × {chart.height} st
diff --git a/components/use-device-pixel-ratio.ts b/components/use-device-pixel-ratio.ts
new file mode 100644
index 0000000..e3fc2d1
--- /dev/null
+++ b/components/use-device-pixel-ratio.ts
@@ -0,0 +1,24 @@
+"use client";
+import { useSyncExternalStore } from "react";
+
+const snapshot = () => window.devicePixelRatio || 1;
+const serverSnapshot = () => 1;
+function subscribe(changed: () => void) {
+ let query = window.matchMedia(`(resolution: ${snapshot()}dppx)`);
+ const update = () => {
+ query.removeEventListener("change", update);
+ query = window.matchMedia(`(resolution: ${snapshot()}dppx)`);
+ query.addEventListener("change", update);
+ changed();
+ };
+ query.addEventListener("change", update);
+ window.addEventListener("resize", update);
+ return () => {
+ query.removeEventListener("change", update);
+ window.removeEventListener("resize", update);
+ };
+}
+/** Match Retina screens, browser zoom, and moves between different monitors. */
+export function useDevicePixelRatio() {
+ return useSyncExternalStore(subscribe, snapshot, serverSnapshot);
+}
diff --git a/components/use-yoke-draft.ts b/components/use-yoke-draft.ts
index 98a273e..52e7308 100644
--- a/components/use-yoke-draft.ts
+++ b/components/use-yoke-draft.ts
@@ -1,12 +1,17 @@
"use client";
import {
startTransition,
+ useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
-import { yokeProjectSchema, type YokeProject } from "@/lib/yoke-project";
+import {
+ yokeProjectSchema,
+ serializeYokeProject,
+ type YokeProject,
+} from "@/lib/yoke-project";
const KEY = "knitplot-yoke-draft-v1";
/** Isolated from regular charts; refuses to overwrite a corrupt or concurrently edited draft. */
@@ -15,15 +20,36 @@ export function useYokeDraft(initial: YokeProject) {
const [ready, setReady] = useState(false);
const [error, setError] = useState("");
const stored = useRef(null);
+ const savedContent = useRef(null);
const latest = useRef(project);
useLayoutEffect(() => {
latest.current = project;
}, [project]);
+ const save = useCallback(() => {
+ try {
+ const text = serializeYokeProject(latest.current);
+ // A read-only window must never rewrite an equivalent draft merely
+ // because JSON fields originally arrived in a different order.
+ if (text === savedContent.current) return true;
+ if (localStorage.getItem(KEY) !== stored.current) throw new Error();
+ if (text !== stored.current) localStorage.setItem(KEY, text);
+ stored.current = text;
+ savedContent.current = text;
+ setError("");
+ return true;
+ } catch {
+ setError(
+ "This yoke draft could not be saved, or another window changed it. Download your work before leaving.",
+ );
+ return false;
+ }
+ }, []);
useEffect(() => {
try {
const text = localStorage.getItem(KEY);
const parsed = text ? yokeProjectSchema.parse(JSON.parse(text)) : initial;
stored.current = text;
+ savedContent.current = text ? serializeYokeProject(parsed) : null;
startTransition(() => {
setProject(parsed);
setReady(true);
@@ -39,22 +65,11 @@ export function useYokeDraft(initial: YokeProject) {
}, [initial]);
useEffect(() => {
if (!ready) return;
- function save() {
- try {
- if (localStorage.getItem(KEY) !== stored.current) throw new Error();
- const text = JSON.stringify(latest.current);
- if (text !== stored.current) localStorage.setItem(KEY, text);
- stored.current = text;
- setError("");
- return true;
- } catch {
- setError(
- "This yoke draft could not be saved, or another window changed it. Download your work before leaving.",
- );
- return false;
- }
- }
const timeout = setTimeout(save, 200);
+ return () => clearTimeout(timeout);
+ }, [project, ready, save]);
+ useEffect(() => {
+ if (!ready) return;
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!save()) {
event.preventDefault();
@@ -68,11 +83,14 @@ export function useYokeDraft(initial: YokeProject) {
window.addEventListener("pagehide", save);
document.addEventListener("visibilitychange", hide);
return () => {
- clearTimeout(timeout);
+ // App Router navigation does not emit pagehide or beforeunload.
+ // Keep this separate from the debounce effect so normal drawing does
+ // not synchronously serialize a large draft on every pointer move.
+ save();
window.removeEventListener("beforeunload", beforeUnload);
window.removeEventListener("pagehide", save);
document.removeEventListener("visibilitychange", hide);
};
- }, [project, ready]);
- return { project, setProject, ready, error };
+ }, [ready, save]);
+ return { project, setProject, ready, error, flush: save };
}
diff --git a/components/use-yoke-navigation.ts b/components/use-yoke-navigation.ts
new file mode 100644
index 0000000..e060183
--- /dev/null
+++ b/components/use-yoke-navigation.ts
@@ -0,0 +1,222 @@
+"use client";
+import {
+ useEffect,
+ useEffectEvent,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type RefObject,
+ type PointerEvent,
+} from "react";
+import { fitYokeZoom } from "@/lib/yoke-view-transform";
+
+export function useYokeNavigation(
+ canvas: RefObject,
+ options: {
+ enabled: boolean;
+ width: number;
+ height: number;
+ scale: number;
+ baseScale: number;
+ sideways: boolean;
+ view: string;
+ fitRequest: number;
+ zoom: number;
+ onZoom?: (zoom: number) => void;
+ },
+) {
+ const {
+ enabled,
+ width,
+ height,
+ scale,
+ baseScale,
+ sideways,
+ view,
+ fitRequest,
+ } = options;
+ const space = useRef(false),
+ over = useRef(false);
+ const pan = useRef<{
+ x: number;
+ y: number;
+ left: number;
+ top: number;
+ } | null>(null);
+ const [cursor, setCursor] = useState<"grab" | "grabbing" | undefined>();
+ const anchor = useRef<{
+ x: number;
+ y: number;
+ offsetX: number;
+ offsetY: number;
+ } | null>(null);
+ const previous = useRef({ scale, sideways, view });
+ function rememberAnchor(clientX: number, clientY: number) {
+ const element = canvas.current,
+ parent = element?.parentElement;
+ if (!element || !parent) return;
+ const box = element.getBoundingClientRect(),
+ bounds = parent.getBoundingClientRect();
+ anchor.current = {
+ x: (clientX - box.left) / box.width,
+ y: (clientY - box.top) / box.height,
+ offsetX: clientX - bounds.left,
+ offsetY: clientY - bounds.top,
+ };
+ }
+ const fit = useEffectEvent(() => {
+ const parent = canvas.current?.parentElement;
+ if (!enabled || !parent || view !== "repeat") return;
+ anchor.current = null;
+ options.onZoom?.(
+ fitYokeZoom(
+ width,
+ height,
+ baseScale,
+ parent.clientWidth,
+ parent.clientHeight,
+ ),
+ );
+ parent.scrollTo({ left: 0, top: 0 });
+ });
+ useEffect(() => {
+ if (fitRequest) fit();
+ }, [fitRequest]);
+ useLayoutEffect(() => {
+ const element = canvas.current,
+ parent = element?.parentElement,
+ before = previous.current;
+ if (
+ enabled &&
+ element &&
+ parent &&
+ before.view === view &&
+ before.sideways === sideways &&
+ before.scale !== scale
+ ) {
+ const point = anchor.current;
+ if (point) {
+ const box = element.getBoundingClientRect(),
+ bounds = parent.getBoundingClientRect();
+ parent.scrollTo({
+ left:
+ parent.scrollLeft +
+ box.left +
+ point.x * box.width -
+ bounds.left -
+ point.offsetX,
+ top:
+ parent.scrollTop +
+ box.top +
+ point.y * box.height -
+ bounds.top -
+ point.offsetY,
+ });
+ } else {
+ const ratio = scale / before.scale;
+ parent.scrollTo({
+ left:
+ (parent.scrollLeft + parent.clientWidth / 2) * ratio -
+ parent.clientWidth / 2,
+ top:
+ (parent.scrollTop + parent.clientHeight / 2) * ratio -
+ parent.clientHeight / 2,
+ });
+ }
+ }
+ previous.current = { scale, sideways, view };
+ }, [enabled, canvas, scale, sideways, view]);
+ const wheel = useEffectEvent((event: WheelEvent) => {
+ if (!(event.ctrlKey || event.metaKey) || pan.current) return;
+ event.preventDefault();
+ rememberAnchor(event.clientX, event.clientY);
+ const next = Math.max(
+ 1,
+ Math.min(
+ 400,
+ Math.round(options.zoom * (event.deltaY < 0 ? 1.15 : 1 / 1.15)),
+ ),
+ );
+ options.onZoom?.(next);
+ });
+ useEffect(() => {
+ const element = canvas.current;
+ if (!enabled || !element) return;
+ const down = (event: KeyboardEvent) => {
+ if (
+ event.code !== "Space" ||
+ (!over.current && document.activeElement !== element) ||
+ (event.target instanceof Element &&
+ event.target.closest("input,textarea,[contenteditable=true]"))
+ )
+ return;
+ event.preventDefault();
+ space.current = true;
+ setCursor("grab");
+ };
+ const reset = () => {
+ space.current = false;
+ pan.current = null;
+ setCursor(undefined);
+ };
+ const up = (event: KeyboardEvent) => {
+ if (event.code === "Space") reset();
+ };
+ const onWheel = (event: WheelEvent) => wheel(event);
+ window.addEventListener("keydown", down);
+ window.addEventListener("keyup", up);
+ window.addEventListener("blur", reset);
+ element.addEventListener("wheel", onWheel, { passive: false });
+ return () => {
+ window.removeEventListener("keydown", down);
+ window.removeEventListener("keyup", up);
+ window.removeEventListener("blur", reset);
+ element.removeEventListener("wheel", onWheel);
+ };
+ }, [enabled, canvas]);
+ return {
+ cursor,
+ enter: () => {
+ over.current = true;
+ },
+ leave: () => {
+ over.current = false;
+ },
+ track: (event: PointerEvent) => {
+ if (enabled && !pan.current) rememberAnchor(event.clientX, event.clientY);
+ },
+ start: (event: PointerEvent) => {
+ if (!enabled || (!space.current && event.button !== 1)) return false;
+ const parent = canvas.current?.parentElement;
+ if (!parent) return false;
+ event.preventDefault();
+ event.currentTarget.focus({ preventScroll: true });
+ event.currentTarget.setPointerCapture(event.pointerId);
+ pan.current = {
+ x: event.clientX,
+ y: event.clientY,
+ left: parent.scrollLeft,
+ top: parent.scrollTop,
+ };
+ setCursor("grabbing");
+ return true;
+ },
+ move: (event: PointerEvent) => {
+ const start = pan.current,
+ parent = canvas.current?.parentElement;
+ if (!start || !parent) return false;
+ event.preventDefault();
+ parent.scrollTo({
+ left: start.left + start.x - event.clientX,
+ top: start.top + start.y - event.clientY,
+ });
+ return true;
+ },
+ stop: () => {
+ const wasPanning = !!pan.current;
+ pan.current = null;
+ setCursor(space.current ? "grab" : undefined);
+ return wasPanning;
+ },
+ };
+}
diff --git a/components/use-yoke-stitches.ts b/components/use-yoke-stitches.ts
new file mode 100644
index 0000000..268b743
--- /dev/null
+++ b/components/use-yoke-stitches.ts
@@ -0,0 +1,349 @@
+"use client";
+import { repeatTargets, type YokeRepeatScope } from "@/lib/yoke-repeat-scope";
+import { yokeRepeatNumber } from "@/lib/yoke-repeat-labels";
+import { useMemo, useRef, useState } from "react";
+import {
+ floodFill,
+ stitchActionOptions,
+ type ChartDocument,
+ type StitchAction,
+ type StitchPaletteAction,
+ type CellSelection,
+} from "@/lib/chart";
+import { readChartDocument } from "@/lib/chart-schema";
+import { yokeProgramFromProject } from "@/lib/yoke-program-from-project";
+import {
+ resolvedYokeProgram,
+ withYokeProgram,
+ yokeCellRepeat,
+} from "@/lib/yoke-program-state";
+import {
+ reviseYokeStitches,
+ type YokeStitchEdit,
+} from "@/lib/yoke-stitch-revision";
+import { yokeDocument } from "@/lib/yoke-document";
+import type { yokeDrawing } from "@/lib/yoke-editing";
+export type YokeStitchDraft = {
+ edits: YokeStitchEdit[];
+ past: YokeStitchEdit[][];
+ future: YokeStitchEdit[][];
+};
+export const emptyStitchDraft: YokeStitchDraft = {
+ edits: [],
+ past: [],
+ future: [],
+};
+
+export function useYokeStitches(
+ document: ChartDocument,
+ drawing: ReturnType,
+ scope: YokeRepeatScope,
+ onChange: (doc: ChartDocument) => void,
+ onRemember: () => void,
+ draft: YokeStitchDraft,
+ onDraftChange: (next: YokeStitchDraft) => void,
+) {
+ const project = document.yoke!;
+ const program = useMemo(() => yokeProgramFromProject(project), [project]);
+ const resolved = useMemo(() => resolvedYokeProgram(program), [program]);
+ const [action, setAction] = useState(null),
+ [picker, setPicker] = useState(false);
+ const [error, setError] = useState("");
+ const [pending, setPending] = useState<{
+ base: ChartDocument;
+ document: ChartDocument;
+ repeat: number;
+ } | null>(null);
+ const batch = useRef>(new Map());
+ const failed = useRef(false);
+ const palette =
+ project.stitchPalette ??
+ Array.from(
+ new Set(
+ program.rounds
+ .flat(2)
+ .map((op) => op.action)
+ .filter((action): action is StitchPaletteAction => action !== "knit"),
+ ),
+ );
+ function updateMetadata(next: typeof project) {
+ onRemember();
+ onChange(yokeDocument(next, document.name));
+ }
+ function addTarget(
+ target: { round: number; section: number; operation: string } | null,
+ ) {
+ if (!target || !action) return;
+ const index = program.rounds[target.round][target.section].findIndex(
+ (op) => op.id === target.operation,
+ );
+ const sections = repeatTargets(
+ scope,
+ program.startingCounts.length,
+ target.section,
+ );
+ for (const s of sections) {
+ const op = program.rounds[target.round][s][index];
+ if (!op)
+ throw new Error(
+ `Repeat ${yokeRepeatNumber(s, program.startingCounts.length)} has no stitch at that instruction position. Edit it separately.`,
+ );
+ batch.current.set(`${target.round}/${s}/${op.id}`, {
+ round: target.round,
+ section: s,
+ operation: op.id,
+ action,
+ });
+ }
+ }
+ function targetAt(row: number, column: number) {
+ if (drawing.operationAt) return drawing.operationAt(row, column);
+ const cell = drawing.yokeCell(row, column);
+ if (!cell) return null;
+ const where = yokeCellRepeat(project, cell.row, cell.column);
+ if (!where) return null;
+ const op =
+ resolved[cell.row].sections[where.section][where.within]?.operation;
+ return op
+ ? { round: cell.row, section: where.section, operation: op }
+ : null;
+ }
+ function collectChart(row: number, column: number, fill = false) {
+ if (failed.current) return;
+ try {
+ if (fill) {
+ const source =
+ drawing.document.stitchActions ??
+ drawing.document.cells.map((line) => line.map(() => "knit"));
+ const marked = floodFill(source, row, column, "selected");
+ marked.forEach((line, r) =>
+ line.forEach((value, c) => {
+ if (value === "selected") addTarget(targetAt(r, c));
+ }),
+ );
+ } else addTarget(targetAt(row, column));
+ } catch (error) {
+ failed.current = true;
+ batch.current.clear();
+ setError(
+ error instanceof Error
+ ? error.message
+ : "Could not select those stitches.",
+ );
+ }
+ }
+ function finish() {
+ const edits = [...batch.current.values()];
+ batch.current.clear();
+ if (failed.current || !edits.length) return;
+ try {
+ const plain = (a: StitchAction) => a === "knit" || a === "purl";
+ const sameShape = edits.every(
+ (edit) =>
+ plain(edit.action) &&
+ plain(
+ program.rounds[edit.round][edit.section].find(
+ (op) => op.id === edit.operation,
+ )!.action,
+ ),
+ );
+ if (sameShape && !draft.edits.length) {
+ const next = readChartDocument(
+ yokeDocument(
+ withYokeProgram(project, reviseYokeStitches(program, edits)),
+ document.name,
+ ),
+ );
+ onRemember();
+ onChange(next);
+ } else {
+ const combined = new Map(
+ draft.edits.map((edit) => [
+ `${edit.round}/${edit.section}/${edit.operation}`,
+ edit,
+ ]),
+ );
+ edits.forEach((edit) =>
+ combined.set(`${edit.round}/${edit.section}/${edit.operation}`, edit),
+ );
+ onDraftChange({
+ edits: [...combined.values()],
+ past: [...draft.past.slice(-39), draft.edits],
+ future: [],
+ });
+ }
+ setError("");
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : "This shaping could not be applied.",
+ );
+ }
+ }
+ function review() {
+ if (!draft.edits.length) return;
+ try {
+ const next = readChartDocument(
+ yokeDocument(
+ withYokeProgram(project, reviseYokeStitches(program, draft.edits)),
+ document.name,
+ ),
+ );
+ setPending({
+ base: document,
+ document: next,
+ repeat: draft.edits[0].section,
+ });
+ setError("");
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : "This shaping could not be applied.",
+ );
+ }
+ }
+ const staged = new Map(
+ draft.edits.map((edit) => [
+ `${edit.round}/${edit.section}/${edit.operation}`,
+ edit.action,
+ ]),
+ );
+ const markedDrawing = draft.edits.length
+ ? {
+ ...drawing,
+ document: {
+ ...drawing.document,
+ stitchActions: drawing.document.cells.map((line, r) =>
+ line.map((_, c) => {
+ const target = targetAt(r, c);
+ return (
+ (target &&
+ staged.get(
+ `${target.round}/${target.section}/${target.operation}`,
+ )) ??
+ drawing.document.stitchActions?.[r][c] ??
+ "knit"
+ );
+ }),
+ ),
+ },
+ }
+ : drawing;
+ const marks: Record = {};
+ if (draft.edits.length)
+ resolved.forEach((round, r) => {
+ let column = 0;
+ round.sections.forEach((loops, s) =>
+ loops.forEach((loop) => {
+ const action = staged.get(`${r}/${s}/${loop.operation}`);
+ if (action) marks[`${r}:${column}`] = action;
+ column++;
+ }),
+ );
+ });
+ function changeColor(stitch: StitchPaletteAction, hex?: string) {
+ const colors = { ...project.stitchColors };
+ if (hex) colors[stitch] = hex;
+ else delete colors[stitch];
+ let next = { ...project, stitchColors: colors };
+ if (project.program) {
+ const p = structuredClone(project.program);
+ p.rounds.forEach((round) =>
+ round.forEach((ops) =>
+ ops.forEach((op) => {
+ if (op.action === stitch) delete op.displayColor;
+ }),
+ ),
+ );
+ next = { ...withYokeProgram(next, p), stitchColors: colors };
+ }
+ updateMetadata(next);
+ }
+ return {
+ action,
+ setAction,
+ picker,
+ setPicker,
+ palette,
+ error,
+ pending,
+ setPending,
+ draft,
+ markedDrawing,
+ marks,
+ review,
+ discard: () => {
+ onDraftChange(emptyStitchDraft);
+ setPending(null);
+ setError("");
+ },
+ undoDraft: () => {
+ if (!draft.past.length) return;
+ onDraftChange({
+ edits: draft.past.at(-1)!,
+ past: draft.past.slice(0, -1),
+ future: [draft.edits, ...draft.future].slice(0, 40),
+ });
+ },
+ redoDraft: () => {
+ if (!draft.future.length) return;
+ onDraftChange({
+ edits: draft.future[0],
+ past: [...draft.past, draft.edits].slice(-40),
+ future: draft.future.slice(1),
+ });
+ },
+ select: (next: StitchAction) => {
+ setAction(next);
+ setPicker(false);
+ },
+ add: (next: StitchPaletteAction) => {
+ updateMetadata({
+ ...project,
+ stitchPalette: palette.includes(next) ? palette : [...palette, next],
+ });
+ setAction(next);
+ setPicker(false);
+ },
+ remove: (next: StitchPaletteAction) => {
+ updateMetadata({
+ ...project,
+ stitchPalette: palette.filter((op) => op !== next),
+ });
+ if (action === next) setAction(null);
+ },
+ changeColor,
+ begin: () => {
+ batch.current.clear();
+ failed.current = false;
+ },
+ finish,
+ collectChart,
+ paint: (row: number, column: number, fill = false) => {
+ const cell = drawing.chartCell(row, column);
+ collectChart(cell.row, cell.column, fill);
+ },
+ applySelection: (selection: CellSelection | null) => {
+ if (!selection) return;
+ batch.current.clear();
+ failed.current = false;
+ for (
+ let r = Math.min(selection.startRow, selection.endRow);
+ r <= Math.max(selection.startRow, selection.endRow);
+ r++
+ )
+ for (
+ let c = Math.min(selection.startColumn, selection.endColumn);
+ c <= Math.max(selection.startColumn, selection.endColumn);
+ c++
+ )
+ collectChart(r, c);
+ finish();
+ },
+ label:
+ stitchActionOptions.find((option) => option.value === action)
+ ?.shortLabel ?? "stitch",
+ };
+}
diff --git a/components/use-yoke-tools.ts b/components/use-yoke-tools.ts
new file mode 100644
index 0000000..c5fd1ff
--- /dev/null
+++ b/components/use-yoke-tools.ts
@@ -0,0 +1,193 @@
+"use client";
+import { useMemo, useState } from "react";
+import { type CellSelection, type Tool } from "@/lib/chart";
+import {
+ canRotateSelection,
+ type SelectionEdit,
+ type SelectionBlock,
+} from "@/lib/chart-selection";
+import {
+ yokeDrawing,
+ copyYokeSelection,
+ editYokeSelection,
+ fillYoke,
+ copyYokeRepeat,
+} from "@/lib/yoke-editing";
+import {
+ paintYoke,
+ yokeProjectSchema,
+ type YokeProject,
+} from "@/lib/yoke-project";
+import type { YokeView } from "@/lib/yoke-layout";
+import { yokeCellRepeat } from "@/lib/yoke-program-state";
+import { mirrorYoke } from "@/lib/yoke-mirror";
+import { mirrorYokeRepeat } from "@/lib/yoke-repeat-mirror";
+import type { YokeRepeatScope } from "@/lib/yoke-repeat-scope";
+import { yokeRepeatNumber } from "@/lib/yoke-repeat-labels";
+
+export type YokeTool = Tool | "row-fill";
+export function useYokeTools(
+ project: YokeProject,
+ view: YokeView,
+ change: (next: YokeProject, remember?: boolean) => void,
+ hasPendingShaping: boolean,
+) {
+ const [tool, setTool] = useState("pencil");
+ const [editScope, setEditScope] = useState(true);
+ const [repeat, setRepeat] = useState(0);
+ const activeRepeat = Math.min(repeat, project.measurements.repeats - 1);
+ const allRepeats = editScope === true;
+ const group =
+ typeof editScope === "boolean"
+ ? []
+ : editScope.filter((s) => s < project.measurements.repeats);
+ const scope: YokeRepeatScope = allRepeats || (group.length ? group : false);
+ const single = view === "chart" || view === "repeat";
+ const drawing = useMemo(
+ () => yokeDrawing(project, single ? activeRepeat : null),
+ [project, single, activeRepeat],
+ );
+ const [selection, setSelection] = useState(null);
+ const [clipboard, setClipboard] = useState(null);
+ const [error, setError] = useState("");
+ function edit(action: SelectionEdit) {
+ try {
+ const next = editYokeSelection(
+ project,
+ drawing,
+ selection,
+ action,
+ scope,
+ );
+ if (next.project !== project) change(next.project);
+ setSelection(next.selection);
+ setError("");
+ } catch (error) {
+ setError(
+ error instanceof Error ? error.message : "Could not edit selection.",
+ );
+ }
+ }
+ function copy() {
+ setClipboard(copyYokeSelection(drawing.document, selection));
+ }
+ function transform(transform: "horizontal" | "vertical" | "rotate") {
+ try {
+ if (!selection) {
+ if (transform !== "horizontal") return;
+ if (hasPendingShaping)
+ throw new Error(
+ "Apply or discard pending shaping edits before mirroring the yoke.",
+ );
+ change(
+ yokeProjectSchema.parse(
+ allRepeats
+ ? mirrorYoke(project)
+ : mirrorYokeRepeat(project, group.length ? group : activeRepeat),
+ ),
+ );
+ setError("");
+ return;
+ }
+ const next = editYokeSelection(
+ project,
+ drawing,
+ selection,
+ { type: "transform", transform },
+ scope,
+ );
+ if (next.project !== project) change(next.project);
+ setSelection(next.selection);
+ setError("");
+ } catch (error) {
+ setError(
+ error instanceof Error ? error.message : "Could not transform design.",
+ );
+ }
+ }
+ return {
+ copyRepeatTo: (targets: readonly number[]) => {
+ const next = copyYokeRepeat(project, activeRepeat, targets);
+ if (next !== project) change(next);
+ },
+ group,
+ scope,
+ setScope: (next: YokeRepeatScope) => {
+ setEditScope(next);
+ if (
+ typeof next !== "boolean" &&
+ next.length &&
+ !next.includes(activeRepeat)
+ )
+ setRepeat(next[0]);
+ setSelection(null);
+ },
+ tool,
+ setTool,
+ allRepeats,
+ repeat: activeRepeat,
+ repeatNumber: yokeRepeatNumber(activeRepeat, project.measurements.repeats),
+ setRepeat: (value: number) => {
+ setRepeat(value);
+ setSelection(null);
+ },
+ drawing,
+ selection,
+ setSelection,
+ error,
+ clipboard,
+ edit,
+ copy,
+ transform,
+ cut: () => {
+ copy();
+ edit({ type: "cut" });
+ },
+ paste: () => {
+ if (clipboard) edit({ type: "paste", block: clipboard });
+ },
+ canRotate: !!selection && canRotateSelection(drawing.document, selection),
+ clear: () => edit({ type: "fill-yarn", color: "yarn-0" }),
+ clearAll: () => {
+ const next = editYokeSelection(
+ project,
+ drawing,
+ {
+ startRow: 0,
+ startColumn: 0,
+ endRow: drawing.document.height - 1,
+ endColumn: drawing.document.width - 1,
+ },
+ { type: "fill-yarn", color: "yarn-0" },
+ scope,
+ );
+ if (next.project !== project) change(next.project);
+ setSelection(null);
+ },
+ paint: (row: number, column: number, color: number) => {
+ const value = tool === "eraser" ? 0 : color;
+ if (!single) {
+ const cell = yokeCellRepeat(project, row, column);
+ if (
+ cell &&
+ (!group.length || allRepeats || group.includes(cell.section))
+ )
+ setRepeat(cell.section);
+ }
+ const next =
+ tool === "fill" || tool === "row-fill"
+ ? fillYoke(
+ project,
+ drawing,
+ row,
+ column,
+ value,
+ scope,
+ tool === "row-fill",
+ )
+ : paintYoke(project, row, column, value, scope);
+ if (next !== project) change(next, false);
+ },
+ };
+}
+export type YokeTools = ReturnType;
diff --git a/components/yoke-canvas-label.ts b/components/yoke-canvas-label.ts
new file mode 100644
index 0000000..04cb4f7
--- /dev/null
+++ b/components/yoke-canvas-label.ts
@@ -0,0 +1,18 @@
+/** Keep chart labels readable when only the viewing surface is rotated. */
+export function drawYokeLabel(
+ context: CanvasRenderingContext2D,
+ text: string,
+ x: number,
+ y: number,
+ rotation: number,
+) {
+ if (!rotation) {
+ context.fillText(text, x, y);
+ return;
+ }
+ context.save();
+ context.translate(x, y);
+ context.rotate(rotation);
+ context.fillText(text, 0, 0);
+ context.restore();
+}
diff --git a/components/yoke-canvas-render.ts b/components/yoke-canvas-render.ts
new file mode 100644
index 0000000..9215e3e
--- /dev/null
+++ b/components/yoke-canvas-render.ts
@@ -0,0 +1,209 @@
+import {
+ contrastTextColor,
+ chartCellDisplayColor,
+ type CellSelection,
+ type StitchAction,
+ stitchActionSymbol,
+} from "@/lib/chart";
+import type { YokeProject } from "@/lib/yoke-project";
+import { yokeChart } from "@/lib/yoke-chart";
+import { yokeGeometry, type YokeView } from "@/lib/yoke-layout";
+import { planYokeShortRows } from "@/lib/yoke-short-rows";
+import { shortRowViewCells } from "@/lib/yoke-short-row-layout";
+import { drawYokeProgramCanvas } from "./yoke-program-canvas-render";
+import { drawYokeLabel } from "./yoke-canvas-label";
+
+export function drawYokeCanvas(
+ context: CanvasRenderingContext2D,
+ project: YokeProject,
+ view: YokeView,
+ repeat: number,
+ scale: number,
+ selection: CellSelection | null,
+ highlight: boolean,
+ marks?: Record,
+ labelRotation = 0,
+) {
+ if (project.program) {
+ drawYokeProgramCanvas(
+ context,
+ project,
+ view,
+ repeat,
+ scale,
+ selection,
+ highlight,
+ marks,
+ labelRotation,
+ );
+ return;
+ }
+ const g = yokeGeometry(
+ project.cells.map((row) => row.length),
+ project.measurements.repeats,
+ view,
+ project.measurements.gaugeRows / project.measurements.gaugeStitches,
+ );
+ const chart = yokeChart(project);
+ const bounds = selection && {
+ r0: Math.min(selection.startRow, selection.endRow),
+ r1: Math.max(selection.startRow, selection.endRow),
+ c0: Math.min(selection.startColumn, selection.endColumn),
+ c1: Math.max(selection.startColumn, selection.endColumn),
+ };
+ function point(row: number, column: number, radial: number) {
+ const count = g.shownCounts[row];
+ const a = g.startAngle + ((column + 0.5) / count) * g.arc;
+ const radius = g.inner + (row + radial) * g.radialHeight;
+ return { x: g.cx + Math.cos(a) * radius, y: g.cy + Math.sin(a) * radius };
+ }
+ if (view === "whole" || view === "front" || view === "back") {
+ try {
+ for (const cell of shortRowViewCells(
+ planYokeShortRows(project.measurements, project.shortRows),
+ g.inner,
+ g.radialHeight,
+ view,
+ )) {
+ context.beginPath();
+ context.arc(g.cx, g.cy, cell.inner, cell.start, cell.end);
+ context.arc(g.cx, g.cy, cell.outer, cell.end, cell.start, true);
+ context.closePath();
+ context.fillStyle = project.palette[0];
+ context.fill();
+ context.strokeStyle = "rgba(190,119,85,.6)";
+ context.lineWidth = 0.6 / scale;
+ context.stroke();
+ }
+ } catch {
+ /* Optional short-row plans do not prevent full-round drawing. */
+ }
+ }
+ project.cells.forEach((row, r) => {
+ const count = g.shownCounts[r];
+ const perRepeat = row.length / project.measurements.repeats;
+ const offset = view === "repeat" ? repeat * perRepeat : g.columnOffsets[r];
+ for (let c = 0; c < count; c++) {
+ const absolute = (c + offset) % row.length;
+ const action =
+ marks?.[`${r}:${absolute}`] ??
+ chart.document.stitchActions![project.cells.length - 1 - r][
+ chart.maps[r][absolute % perRepeat]
+ ];
+ const background = chartCellDisplayColor(
+ chart.document,
+ project.cells.length - 1 - r,
+ chart.maps[r][absolute % perRepeat],
+ project.palette[row[absolute]],
+ );
+ const a = g.startAngle + (c / count) * g.arc,
+ b = g.startAngle + ((c + 1) / count) * g.arc;
+ const inner = g.inner + r * g.radialHeight,
+ outer = inner + g.radialHeight;
+ context.beginPath();
+ context.arc(g.cx, g.cy, inner, a, b);
+ context.arc(g.cx, g.cy, outer, b, a, true);
+ context.closePath();
+ context.fillStyle = background;
+ context.fill();
+ context.strokeStyle = "rgba(45,52,45,.45)";
+ context.lineWidth = 1 / scale;
+ context.stroke();
+ const selected = bounds
+ ? {
+ row: project.cells.length - 1 - r,
+ column:
+ chart.maps[r][absolute % perRepeat] +
+ (view === "repeat"
+ ? 0
+ : Math.floor(absolute / perRepeat) * chart.document.width),
+ }
+ : null;
+ if (
+ bounds &&
+ selected &&
+ selected.row >= bounds.r0 &&
+ selected.row <= bounds.r1 &&
+ selected.column >= bounds.c0 &&
+ selected.column <= bounds.c1
+ ) {
+ context.fillStyle = "rgba(76,126,168,.25)";
+ context.fill();
+ context.strokeStyle = "#346ca0";
+ context.lineWidth = 1.5 / scale;
+ context.stroke();
+ }
+ if (action !== "knit") {
+ context.strokeStyle = "#b55b27";
+ context.lineWidth = 2 / scale;
+ context.stroke();
+ const p = point(r, c, 0.55);
+ context.fillStyle = contrastTextColor(background);
+ context.font = `bold ${Math.min(g.radialHeight * 0.55, 10 / scale)}px sans-serif`;
+ context.textAlign = "center";
+ context.textBaseline = "middle";
+ drawYokeLabel(
+ context,
+ stitchActionSymbol(action),
+ p.x,
+ p.y,
+ labelRotation,
+ );
+ if (action === "m1l" || action === "m1r") {
+ const source = point(r, c + (action === "m1l" ? 0.3 : -0.3), 0);
+ context.beginPath();
+ context.moveTo(source.x, source.y);
+ context.lineTo(p.x, p.y);
+ context.strokeStyle = "#b55b27";
+ context.lineWidth = 1.5 / scale;
+ context.stroke();
+ }
+ }
+ // Existing columns cross an increase round according to stitch ancestry,
+ // not simply the same fraction of the circle on both rows.
+ if (
+ r > 0 &&
+ project.cells[r - 1].length !== row.length &&
+ action === "knit"
+ ) {
+ const parent = chart.maps[r - 1].indexOf(
+ chart.maps[r][absolute % perRepeat],
+ );
+ const oldPerRepeat =
+ project.cells[r - 1].length / project.measurements.repeats;
+ const oldAbsolute =
+ Math.floor(absolute / perRepeat) * oldPerRepeat + parent;
+ const oldOffset =
+ view === "repeat" ? repeat * oldPerRepeat : g.columnOffsets[r - 1];
+ const oldColumn =
+ (oldAbsolute - oldOffset + project.cells[r - 1].length) %
+ project.cells[r - 1].length;
+ if (parent >= 0 && oldColumn < g.shownCounts[r - 1]) {
+ const from = point(r - 1, oldColumn, 0.78),
+ to = point(r, c, 0.22);
+ context.beginPath();
+ context.moveTo(from.x, from.y);
+ context.lineTo(to.x, to.y);
+ context.strokeStyle = "rgba(65,82,69,.5)";
+ context.lineWidth = 1 / scale;
+ context.stroke();
+ }
+ }
+ }
+ if (highlight && view !== "repeat") {
+ for (let c = 0; c < count; c++) {
+ const absolute = (c + offset) % row.length;
+ if (Math.floor(absolute / perRepeat) !== repeat) continue;
+ const a = g.startAngle + (c / count) * g.arc,
+ b = g.startAngle + ((c + 1) / count) * g.arc;
+ context.beginPath();
+ context.arc(g.cx, g.cy, g.inner + r * g.radialHeight, a, b);
+ context.arc(g.cx, g.cy, g.inner + (r + 1) * g.radialHeight, b, a, true);
+ context.closePath();
+ context.strokeStyle = "rgba(51,108,160,.55)";
+ context.lineWidth = 1 / scale;
+ context.stroke();
+ }
+ }
+ });
+}
diff --git a/components/yoke-canvas.tsx b/components/yoke-canvas.tsx
index 9ab7a3b..3e89285 100644
--- a/components/yoke-canvas.tsx
+++ b/components/yoke-canvas.tsx
@@ -1,12 +1,27 @@
"use client";
-import { useEffect, useRef, type PointerEvent } from "react";
+import {
+ useEffect,
+ useEffectEvent,
+ useMemo,
+ useRef,
+ type PointerEvent,
+} from "react";
+import type { CellSelection, StitchAction } from "@/lib/chart";
import type { YokeProject } from "@/lib/yoke-project";
+import { yokeCellAt, yokeGeometry, type YokeView } from "@/lib/yoke-layout";
+import { yokeCanvasViewport, yokeBitmapScale } from "@/lib/yoke-canvas-view";
+import { useDevicePixelRatio } from "./use-device-pixel-ratio";
+import { yokeDrawing } from "@/lib/yoke-editing";
+import { drawYokeCanvas } from "./yoke-canvas-render";
+import { useYokeNavigation } from "./use-yoke-navigation";
+import {
+ presentYokePoint,
+ unpresentYokePoint,
+} from "@/lib/yoke-view-transform";
import {
- YOKE_CANVAS,
- yokeGeometry,
- yokeCellAt,
- type YokeView,
-} from "@/lib/yoke-layout";
+ yokeProgramSurface,
+ yokeProgramGeometry,
+} from "@/lib/yoke-program-surface";
type Props = {
project: YokeProject;
@@ -14,6 +29,16 @@ type Props = {
zoom: number;
onPaint: (row: number, column: number) => void;
onStrokeStart: () => void;
+ repeat?: number;
+ select?: boolean;
+ selection?: CellSelection | null;
+ onSelectionChange?: (value: CellSelection | null) => void;
+ highlight?: boolean;
+ onStrokeEnd?: () => void;
+ marks?: Record;
+ sideways?: boolean;
+ fitRequest?: number;
+ onZoomChange?: (zoom: number) => void;
};
export function YokeCanvas({
project,
@@ -21,128 +46,252 @@ export function YokeCanvas({
zoom,
onPaint,
onStrokeStart,
+ repeat = 0,
+ select = false,
+ selection = null,
+ onSelectionChange,
+ highlight = false,
+ onStrokeEnd,
+ marks,
+ sideways: requestedSideways = false,
+ fitRequest = 0,
+ onZoomChange,
}: Props) {
+ const sideways = requestedSideways && view === "repeat";
const ref = useRef(null);
- const drawing = useRef(false);
- const counts = project.cells.map((row) => row.length);
+ const pointer = useRef<{
+ start: { row: number; column: number } | null;
+ last: string;
+ active: boolean;
+ }>({ start: null, last: "", active: false });
+ const surface = project.program ? yokeProgramSurface(project) : null;
+ const counts = surface
+ ? surface.rows.map((row) => row.length)
+ : project.cells.map((row) => row.length);
const aspect =
project.measurements.gaugeRows / project.measurements.gaugeStitches;
+ const geometry = surface
+ ? yokeProgramGeometry(project, view, repeat)
+ : yokeGeometry(counts, project.measurements.repeats, view, aspect);
+ const viewport = yokeCanvasViewport(
+ counts,
+ project.measurements.repeats,
+ view,
+ aspect,
+ geometry,
+ );
+ const displayScale = (viewport.scale * zoom) / 100;
+ const displayWidth = sideways ? viewport.height : viewport.width;
+ const displayHeight = sideways ? viewport.width : viewport.height;
+ const navigation = useYokeNavigation(ref, {
+ enabled: !!onZoomChange,
+ width: displayWidth,
+ height: displayHeight,
+ scale: displayScale,
+ baseScale: viewport.scale,
+ sideways,
+ view,
+ fitRequest,
+ zoom,
+ onZoom: onZoomChange,
+ });
+ const pixelRatio = useDevicePixelRatio();
+ const renderScale = yokeBitmapScale(
+ viewport.width,
+ viewport.height,
+ displayScale,
+ pixelRatio,
+ );
+ const drawing = useMemo(
+ () =>
+ yokeDrawing(
+ project,
+ view === "repeat" || view === "chart" ? repeat : null,
+ ),
+ [project, view, repeat],
+ );
+ const recenter = useEffectEvent(() => {
+ const canvas = ref.current,
+ parent = canvas?.parentElement;
+ if (!canvas || !parent) return;
+ // Start on real stitches rather than the empty corner of a large circle.
+ const g = geometry;
+ const angle =
+ view === "whole"
+ ? g.startAngle + ((repeat + 0.5) / project.measurements.repeats) * g.arc
+ : g.startAngle + g.arc / 2;
+ const radius =
+ g.inner + Math.min(4, project.cells.length / 2) * g.radialHeight;
+ const v = viewport;
+ const scale = (v.scale * zoom) / 100;
+ const focus = presentYokePoint(
+ {
+ x: g.cx + Math.cos(angle) * radius - v.x,
+ y: g.cy + Math.sin(angle) * radius - v.y,
+ },
+ v.width,
+ sideways,
+ );
+ parent.scrollTo({
+ left: focus.x * scale - parent.clientWidth / 2,
+ top: focus.y * scale - parent.clientHeight / 2,
+ });
+ });
+ // Do not recenter while painting, changing the selected repeat or zooming.
useEffect(() => {
- const canvas = ref.current;
- const context = canvas?.getContext("2d");
+ recenter();
+ }, [view, sideways]);
+ const previousScale = useRef({ view, scale: displayScale });
+ useEffect(() => {
+ const previous = previousScale.current,
+ parent = ref.current?.parentElement;
+ if (
+ !onZoomChange &&
+ parent &&
+ previous.view === view &&
+ previous.scale !== displayScale
+ ) {
+ const ratio = displayScale / previous.scale;
+ parent.scrollTo({
+ left:
+ (parent.scrollLeft + parent.clientWidth / 2) * ratio -
+ parent.clientWidth / 2,
+ top:
+ (parent.scrollTop + parent.clientHeight / 2) * ratio -
+ parent.clientHeight / 2,
+ });
+ }
+ previousScale.current = { view, scale: displayScale };
+ }, [view, displayScale, onZoomChange]);
+ useEffect(() => {
+ const canvas = ref.current,
+ context = canvas?.getContext("2d");
if (!canvas || !context) return;
+ context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
- const g = yokeGeometry(
- project.cells.map((row) => row.length),
- project.measurements.repeats,
+ context.setTransform(renderScale, 0, 0, renderScale, 0, 0);
+ if (sideways) context.transform(0, -1, 1, 0, 0, viewport.width);
+ context.translate(-viewport.x, -viewport.y);
+ drawYokeCanvas(
+ context,
+ project,
view,
- aspect,
+ repeat,
+ displayScale,
+ selection,
+ highlight,
+ marks,
+ sideways ? Math.PI / 2 : 0,
);
- const increaseRows = new Set(
- project.events.map((event) => event.round - 1),
- );
- project.cells.forEach((row, r) => {
- const count = g.shownCounts[r];
- const bottom = YOKE_CANVAS.height - 40 - r * g.rowHeight;
- for (let c = 0; c < count; c++) {
- context.fillStyle = project.palette[row[c]];
- context.beginPath();
- if (view === "chart") {
- context.rect(
- (canvas.width - count * g.cellWidth) / 2 + c * g.cellWidth,
- bottom - g.rowHeight,
- g.cellWidth,
- g.rowHeight,
- );
- } else {
- const a = g.startAngle + (c / count) * g.arc;
- const b = g.startAngle + ((c + 1) / count) * g.arc;
- const inner = g.inner + r * g.radialHeight;
- const outer = inner + g.radialHeight;
- context.arc(g.cx, g.cy, inner, a, b);
- context.arc(g.cx, g.cy, outer, b, a, true);
- context.closePath();
- }
- context.fill();
- context.strokeStyle = "rgba(45,52,45,.22)";
- context.lineWidth = 0.5;
- context.stroke();
- }
- if (increaseRows.has(r)) {
- context.beginPath();
- if (view === "chart") {
- context.moveTo((canvas.width - count * g.cellWidth) / 2, bottom);
- context.lineTo((canvas.width + count * g.cellWidth) / 2, bottom);
- } else {
- context.arc(
- g.cx,
- g.cy,
- g.inner + r * g.radialHeight,
- g.startAngle,
- g.startAngle + g.arc,
- );
- }
- context.strokeStyle = "#be7755";
- context.lineWidth = 1.5;
- context.stroke();
- }
- });
- context.fillStyle = "#334c44";
- context.font = "15px sans-serif";
- context.textAlign = "center";
- context.fillText(
- view === "whole"
- ? "Back · start of round"
- : view === "repeat"
- ? "Neck edge · round 1"
- : "Neck edge · round 1 at bottom",
- 500,
- view === "chart" ? 790 : 15,
- );
- if (view === "whole") context.fillText("Front", 500, 790);
- }, [project, view, aspect]);
-
- function paint(event: PointerEvent) {
+ }, [
+ project,
+ view,
+ repeat,
+ displayScale,
+ renderScale,
+ selection,
+ highlight,
+ marks,
+ sideways,
+ viewport.x,
+ viewport.y,
+ viewport.width,
+ ]);
+ function cell(event: PointerEvent) {
const box = event.currentTarget.getBoundingClientRect();
- const cell = yokeCellAt(
+ const point = unpresentYokePoint(
+ {
+ x: ((event.clientX - box.left) / box.width) * displayWidth,
+ y: ((event.clientY - box.top) / box.height) * displayHeight,
+ },
+ viewport.width,
+ sideways,
+ );
+ const result = yokeCellAt(
counts,
project.measurements.repeats,
view,
aspect,
- ((event.clientX - box.left) / box.width) * YOKE_CANVAS.width,
- ((event.clientY - box.top) / box.height) * YOKE_CANVAS.height,
+ viewport.x + point.x,
+ viewport.y + point.y,
+ geometry,
);
- if (cell) onPaint(cell.row, cell.column);
+ if (result && surface) {
+ const slot = surface.rows[result.row][result.column];
+ return slot && !slot.gap ? { row: result.row, column: slot.loop } : null;
+ }
+ if (result && (view === "repeat" || view === "chart"))
+ result.column +=
+ (repeat * counts[result.row]) / project.measurements.repeats;
+ return result;
+ }
+ function paint(event: PointerEvent) {
+ const found = cell(event);
+ if (!found) return;
+ const key = `${found.row}:${found.column}`;
+ if (pointer.current.last === key) return;
+ pointer.current.last = key;
+ if (select) {
+ const next = drawing.chartCell(found.row, found.column);
+ const start = pointer.current.start ?? next;
+ pointer.current.start = start;
+ onSelectionChange?.({
+ startRow: start.row,
+ startColumn: start.column,
+ endRow: next.row,
+ endColumn: next.column,
+ });
+ } else onPaint(found.row, found.column);
+ }
+ function stop() {
+ if (navigation.stop()) return;
+ const finish = pointer.current.active && !select;
+ pointer.current.active = false;
+ pointer.current.start = null;
+ if (finish) onStrokeEnd?.();
}
return (
-
+
{
- if (event.button !== 0) return;
+ if (navigation.start(event)) return;
+ if (event.button !== 0 || !cell(event)) return;
event.preventDefault();
- onStrokeStart();
- drawing.current = true;
+ if (onZoomChange) event.currentTarget.focus({ preventScroll: true });
+ if (!select) onStrokeStart();
+ pointer.current = { start: null, last: "", active: true };
event.currentTarget.setPointerCapture(event.pointerId);
paint(event);
}}
onPointerMove={(event) => {
- if (drawing.current) paint(event);
- }}
- onPointerUp={() => {
- drawing.current = false;
- }}
- onPointerCancel={() => {
- drawing.current = false;
- }}
- onLostPointerCapture={() => {
- drawing.current = false;
+ if (navigation.move(event)) return;
+ navigation.track(event);
+ if (pointer.current.active) paint(event);
}}
+ onPointerUp={stop}
+ onPointerCancel={stop}
+ onLostPointerCapture={stop}
+ onPointerEnter={navigation.enter}
+ onPointerLeave={navigation.leave}
/>
);
diff --git a/components/yoke-designer.tsx b/components/yoke-designer.tsx
index 240c9ac..4aa034a 100644
--- a/components/yoke-designer.tsx
+++ b/components/yoke-designer.tsx
@@ -1,6 +1,6 @@
"use client";
import Link from "next/link";
-import { useLayoutEffect, useRef, useState } from "react";
+import { useId, useLayoutEffect, useRef, useState } from "react";
import {
calculateYokeTargets,
defaultYokeMeasurements,
@@ -16,8 +16,13 @@ import {
} from "@/lib/yoke-project";
import type { YokeView } from "@/lib/yoke-layout";
import { YokePlanner } from "./yoke-planner";
+import { YokeShortRowPlanner } from "./yoke-short-row-planner";
import { YokeCanvas } from "./yoke-canvas";
+import { YokeStandardChart } from "./yoke-standard-chart";
import { useYokeDraft } from "./use-yoke-draft";
+import { YokeMeasurementHelp } from "./yoke-measurement-help";
+import type { YokeField } from "@/lib/yoke-measurement-fields";
+import type { YokeMeasurementMode } from "@/lib/yoke-measurement-mode";
const initial = createYokeProject(
defaultYokeMeasurements,
@@ -27,7 +32,7 @@ const initial = createYokeProject(
),
);
export function YokeDesigner() {
- const { project, setProject, ready, error } = useYokeDraft(initial);
+ const { project, setProject, ready, error, flush } = useYokeDraft(initial);
const [view, setView] = useState
("repeat");
const [color, setColor] = useState(1);
const [allRepeats, setAllRepeats] = useState(true);
@@ -35,12 +40,21 @@ export function YokeDesigner() {
const [history, setHistory] = useState([]);
const [future, setFuture] = useState([]);
const [message, setMessage] = useState("");
+ const measurementInputId = useId();
+ const [measurementGuide, setMeasurementGuide] = useState<{
+ field: YokeField;
+ mode: YokeMeasurementMode;
+ source: YokeMeasurements;
+ } | null>(null);
+ const [guideOpen, setGuideOpen] = useState(true);
const input = useRef(null);
const latestProject = useRef(project);
useLayoutEffect(() => {
latestProject.current = project;
}, [project]);
const plan = calculateYokeTargets(project.measurements);
+ // Undo/import can restore a smaller palette than the previously selected index.
+ const activeColor = Math.min(color, project.palette.length - 1);
function remember() {
setHistory((old) => [...old.slice(-19), project]);
setFuture([]);
@@ -59,10 +73,26 @@ export function YokeDesigner() {
setMessage("");
}
function apply(measurements: YokeMeasurements, events: IncreaseEvent[]) {
- replace({
+ const next = {
...createYokeProject(measurements, events),
palette: project.palette,
- });
+ };
+ if (
+ next.cells.length === project.cells.length &&
+ next.cells.every(
+ (row, index) => row.length === project.cells[index].length,
+ )
+ ) {
+ remember();
+ setProject({
+ ...next,
+ cells: project.cells,
+ ...(project.shortRows?.distances.length ===
+ calculateYokeTargets(measurements).shortRowPairs
+ ? { shortRows: project.shortRows }
+ : {}),
+ });
+ } else replace(next);
}
function download() {
const url = URL.createObjectURL(
@@ -90,7 +120,20 @@ export function YokeDesigner() {
Development preview
- Chart maker
+ {
+ if (
+ !flush() &&
+ !window.confirm(
+ "Your yoke could not be saved. Stay here and download it to keep your work. Leave without saving?",
+ )
+ )
+ event.preventDefault();
+ }}
+ >
+ Chart maker
+
Download yoke draft
@@ -138,6 +181,43 @@ export function YokeDesigner() {
measurements={project.measurements}
events={project.events}
onApply={apply}
+ inputId={measurementInputId}
+ onMeasurementFocus={(field, mode) => {
+ setMeasurementGuide({ field, mode, source: project.measurements });
+ setGuideOpen(true);
+ }}
+ measurementHelp={
+
+ document
+ .getElementById(`${measurementInputId}-${field}`)
+ ?.focus()
+ }
+ />
+ }
+ />
+ {
+ remember();
+ setProject({ ...project, shortRows });
+ }}
/>
@@ -156,19 +236,41 @@ export function YokeDesigner() {
{(
[
["whole", "Whole yoke"],
+ ["front", "Half yoke"],
["repeat", "One repeat"],
["chart", "Standard chart"],
] as const
).map(([id, label]) => (
setView(id)}
>
{label}
))}
+ {view === "front" || view === "back" ? (
+
+ Show
+
+ setView(event.target.value as "front" | "back")
+ }
+ >
+ Front-facing half
+ Back-facing half
+
+
+ ) : null}
{project.palette.map((hex, index) => (
@@ -176,7 +278,7 @@ export function YokeDesigner() {
setColor(index)}
/>
- Design layout · warm lines mark increase rounds. Repeat and standard
+ Design layout · M1L/M1R cells mark increases. Repeat and standard
views edit the first repeat; the whole-yoke view can edit any
repeat.
-
- setProject((current) =>
- paintYoke(current, row, column, color, allRepeats),
- )
- }
- />
+ {view === "front" || view === "back" ? (
+
+ A stitch-aligned half of the yoke, including parts of the
+ sleeves—not just the front or back body panel.
+
+ ) : null}
+ {view === "chart" ? (
+
+ setProject((current) =>
+ paintYoke(current, row, column, activeColor, allRepeats),
+ )
+ }
+ />
+ ) : (
+
+ setProject((current) =>
+ paintYoke(current, row, column, activeColor, allRepeats),
+ )
+ }
+ />
+ )}
Current shaping plan
@@ -276,7 +398,8 @@ export function YokeDesigner() {
{plan.shortRowPairs} short-row pairs planned before colourwork;
- not included in these full-round drawing views.
+ shown at the back neckline in Whole yoke view, separately from the
+ full chart rounds.
diff --git a/components/yoke-drawing-tools.tsx b/components/yoke-drawing-tools.tsx
new file mode 100644
index 0000000..cd6e727
--- /dev/null
+++ b/components/yoke-drawing-tools.tsx
@@ -0,0 +1,195 @@
+"use client";
+import type { YokeTools } from "./use-yoke-tools";
+const zoomLevels = [1, 5, 10, 15, 25, 50, 75, 100, 150, 200, 300, 400];
+
+export function YokeDrawingTools({
+ tools,
+ color,
+ zoom,
+ minimumZoom = 15,
+ setZoom,
+ undo,
+ redo,
+ canUndo,
+ canRedo,
+ onErase,
+ onFillSelection,
+ selectionLabel,
+}: {
+ tools: YokeTools;
+ color: number;
+ zoom: number;
+ minimumZoom?: number;
+ setZoom: (value: number) => void;
+ undo: () => void;
+ redo: () => void;
+ canUndo: boolean;
+ canRedo: boolean;
+ onErase?: () => void;
+ onFillSelection?: () => void;
+ selectionLabel?: string;
+}) {
+ const { selection } = tools;
+ return (
+
+
Yoke editor
+
+
+ {(
+ [
+ ["pencil", "Draw"],
+ ["fill", "Fill"],
+ ["row-fill", "Fill row section"],
+ ["eraser", "Erase"],
+ ["select", "Select"],
+ ] as const
+ ).map(([value, label]) => (
+ {
+ tools.setTool(value);
+ if (value === "eraser") onErase?.();
+ }}
+ >
+ {label}
+
+ ))}
+ Clear
+
+
+
+ tools.transform("horizontal")}
+ title={
+ selection
+ ? "Mirror selection"
+ : tools.allRepeats
+ ? "Mirror the whole yoke"
+ : tools.group.length > 1
+ ? "Mirror each repeat in the group"
+ : `Mirror repeat ${tools.repeatNumber} only`
+ }
+ >
+ ↔
+
+
+
+
+
+ ↺
+
+
+ ↻
+
+
+
+
+ setZoom(
+ zoomLevels
+ .filter((value) => value >= minimumZoom && value < zoom)
+ .at(-1) ?? minimumZoom,
+ )
+ }
+ >
+ −
+
+ setZoom(Number(event.target.value))}
+ >
+ {[...new Set([...zoomLevels, zoom])]
+ .sort((a, b) => a - b)
+ .map((value) => (
+
+ {value}%
+
+ ))}
+
+ = zoomLevels.at(-1)!}
+ onClick={() =>
+ setZoom(
+ zoomLevels.find((value) => value > zoom) ?? zoomLevels.at(-1)!,
+ )
+ }
+ >
+ +
+
+
+
+
+
+
+ onFillSelection
+ ? onFillSelection()
+ : tools.edit({ type: "fill-yarn", color: `yarn-${color}` })
+ }
+ >
+ {selectionLabel ?? "Fill selection"}
+
+
+ Copy
+
+
+ Cut
+
+
+ Paste
+
+
+ {(
+ [
+ ["left", 0, -1, "←"],
+ ["up", -1, 0, "↑"],
+ ["down", 1, 0, "↓"],
+ ["right", 0, 1, "→"],
+ ] as const
+ ).map(([label, rows, columns, icon]) => (
+ tools.edit({ type: "move", rows, columns })}
+ >
+ {icon}
+
+ ))}
+
+ tools.setSelection(null)}>
+ Deselect
+
+
+
+ );
+}
diff --git a/components/yoke-editor.tsx b/components/yoke-editor.tsx
new file mode 100644
index 0000000..a149d9c
--- /dev/null
+++ b/components/yoke-editor.tsx
@@ -0,0 +1,441 @@
+"use client";
+import { useState, type ReactNode } from "react";
+import type { ChartDocument } from "@/lib/chart";
+import { calculateYokeTargets, type YokeMeasurements } from "@/lib/yoke-plan";
+import { type YokeProject } from "@/lib/yoke-project";
+import { yokeDocument } from "@/lib/yoke-document";
+import type { YokeView } from "@/lib/yoke-layout";
+import { ColourPalettePanel } from "./colour-palette-panel";
+import { YokeCanvas } from "./yoke-canvas";
+import { YokeStandardChart } from "./yoke-standard-chart";
+import { YokeGaugePanel } from "./yoke-gauge-panel";
+import { YokePlanDialog } from "./yoke-plan-dialog";
+import { useYokeTools } from "./use-yoke-tools";
+import { YokeDrawingTools } from "./yoke-drawing-tools";
+import { YokeRepeatControls } from "./yoke-repeat-controls";
+import { StitchPalettePanel } from "./stitch-palette-panel";
+import { useYokeStitches, type YokeStitchDraft } from "./use-yoke-stitches";
+import { YokeStitchRevisionDialog } from "./yoke-stitch-revision-dialog";
+import { withYokeProgram } from "@/lib/yoke-program-state";
+
+export function YokeEditor({
+ document,
+ onChange,
+ onCreate,
+ onStrokeStart,
+ undo,
+ redo,
+ canUndo,
+ canRedo,
+ tabsFull,
+ view,
+ onViewChange,
+ stitchDraft,
+ onStitchDraftChange,
+ children,
+}: {
+ stitchDraft: YokeStitchDraft;
+ onStitchDraftChange: (next: YokeStitchDraft) => void;
+ view: YokeView;
+ onViewChange: (view: YokeView) => void;
+ document: ChartDocument;
+ onChange: (document: ChartDocument) => void;
+ onCreate: (document: ChartDocument) => boolean;
+ children?: ReactNode;
+ onStrokeStart: () => void;
+ undo: () => void;
+ redo: () => void;
+ canUndo: boolean;
+ canRedo: boolean;
+ tabsFull: boolean;
+}) {
+ const project = document.yoke!;
+ const target = calculateYokeTargets(project.measurements);
+ function setView(next: YokeView) {
+ tools.setSelection(null);
+ onViewChange(next);
+ }
+ const [selected, setSelected] = useState("yarn-1");
+ const tools = useYokeTools(
+ project,
+ view,
+ change,
+ stitchDraft.edits.length > 0,
+ );
+ const stitches = useYokeStitches(
+ document,
+ tools.drawing,
+ tools.scope,
+ onChange,
+ onStrokeStart,
+ stitchDraft,
+ onStitchDraftChange,
+ );
+ function beginStroke() {
+ if (stitches.action) stitches.begin();
+ else onStrokeStart();
+ }
+ function selectStitch(action: import("@/lib/chart").StitchAction) {
+ stitches.select(action);
+ if (tools.tool === "eraser") tools.setTool("pencil");
+ }
+ const [zoom, setZoom] = useState(100);
+ const [sideways, setSideways] = useState(false);
+ const [fitRequest, setFitRequest] = useState(0);
+ const visibleZoom = view === "chart" ? Math.max(100, zoom) : zoom;
+ const [planning, setPlanning] = useState<{
+ measurements?: YokeMeasurements;
+ } | null>(null);
+ const color = Math.min(
+ project.palette.length - 1,
+ Number(selected.replace("yarn-", "")) || 0,
+ );
+ function change(next: YokeProject, remember = true) {
+ if (remember) onStrokeStart();
+ onChange(yokeDocument(next, document.name));
+ }
+ return (
+ <>
+
+
+ change({ ...project, palette: [...project.palette, "#c86f52"] })
+ }
+ changePaletteColor={(id, hex) =>
+ change({
+ ...project,
+ palette: project.palette.map((old, i) =>
+ id === `yarn-${i}` ? hex : old,
+ ),
+ })
+ }
+ removeColor={(id) => {
+ const index = Number(id.replace("yarn-", ""));
+ const palette = project.palette.filter((_, i) => i !== index);
+ if (project.program) {
+ const program = structuredClone(project.program);
+ program.rounds.forEach((round) =>
+ round.forEach((ops) =>
+ ops.forEach((op) => {
+ op.color =
+ op.color === index
+ ? 0
+ : op.color > index
+ ? op.color - 1
+ : op.color;
+ }),
+ ),
+ );
+ change(withYokeProgram({ ...project, palette }, program));
+ } else
+ change({
+ ...project,
+ palette,
+ cells: project.cells.map((row) =>
+ row.map((c) => (c === index ? 0 : c > index ? c - 1 : c)),
+ ),
+ });
+ }}
+ />
+ {
+ stitches.add(action);
+ if (tools.tool === "eraser") tools.setTool("pencil");
+ }}
+ selectStitchAction={selectStitch}
+ changeStitchColor={(action, hex) => stitches.changeColor(action, hex)}
+ removeStitchFromPalette={stitches.remove}
+ resetStitchColor={(action) => stitches.changeColor(action)}
+ />
+
+
+ Yoke plan ⌄
+
+
+
+ {project.program
+ ? project.program.startingCounts.reduce((a, b) => a + b, 0)
+ : target.neck}{" "}
+ → {project.cells.at(-1)!.length} stitches
+
+
+ {target.rounds} rounds · {project.measurements.repeats} repeats
+
+
+ {target.actualDepth.toFixed(1)} {project.measurements.unit} deep ·{" "}
+ {target.shortRowPairs} short-row pairs
+
+
setPlanning({})}
+ >
+ Edit shaping
+
+
+
+ {
+ onStrokeStart();
+ onChange(next);
+ }}
+ onRecalculate={(measurements) => setPlanning({ measurements })}
+ />
+
+
+
+ {
+ tools.setSelection(null);
+ undo();
+ }}
+ redo={() => {
+ tools.setSelection(null);
+ redo();
+ }}
+ canUndo={canUndo}
+ canRedo={canRedo}
+ onErase={() => stitches.setAction(null)}
+ selectionLabel={
+ stitches.action ? `Apply ${stitches.label}` : undefined
+ }
+ onFillSelection={
+ stitches.action
+ ? () => stitches.applySelection(tools.selection)
+ : undefined
+ }
+ />
+
+
+ {(
+ [
+ ["chart", "Standard chart"],
+ ["repeat", "One repeat"],
+ ["front", "Half yoke"],
+ ["whole", "Whole yoke"],
+ ] as const
+ ).map(([value, label]) => (
+ setView(value)}
+ >
+ {label}
+
+ ))}
+
+ {view === "front" || view === "back" ? (
+
+ setView(event.target.value as "front" | "back")
+ }
+ >
+ Front-facing half
+ Back-facing half
+
+ ) : null}
+ {view === "repeat" ? (
+
+
+ {
+ setSideways(false);
+ setFitRequest((value) => value + 1);
+ }}
+ >
+ Upright
+
+ {
+ setSideways(true);
+ setFitRequest((value) => value + 1);
+ }}
+ >
+ Sideways
+
+
+
setFitRequest((value) => value + 1)}
+ >
+ Fit whole repeat
+
+
+ ) : null}
+
+
+ {stitches.error ? {stitches.error}
: null}
+ {tools.error ? (
+
+ {tools.error}
+
+ ) : null}
+ {stitches.draft.edits.length || stitches.draft.future.length ? (
+
+
+ {stitches.draft.edits.length} pending shaping edits · not
+ applied yet
+
+
+ Undo shaping edit
+
+
+ Redo shaping edit
+
+
+ Discard shaping edits
+
+
+ Review shaping changes
+
+
+ ) : null}
+ {view === "chart" ? (
+ tools.paint(row, column, color)}
+ onChartPaint={
+ stitches.action
+ ? (row, column) =>
+ stitches.collectChart(
+ row,
+ column,
+ tools.tool === "fill" || tools.tool === "row-fill",
+ )
+ : undefined
+ }
+ onStrokeStart={beginStroke}
+ onStrokeEnd={stitches.action ? stitches.finish : undefined}
+ />
+ ) : (
+
+ stitches.action
+ ? stitches.paint(
+ row,
+ column,
+ tools.tool === "fill" || tools.tool === "row-fill",
+ )
+ : tools.paint(row, column, color)
+ }
+ onStrokeStart={beginStroke}
+ onStrokeEnd={stitches.action ? stitches.finish : undefined}
+ />
+ )}
+
+ {children}
+
+ {stitches.pending ? (
+ {
+ onStrokeStart();
+ onChange(next);
+ tools.setSelection(null);
+ stitches.discard();
+ }}
+ onCreate={(next) => {
+ const created = onCreate(next);
+ if (created) stitches.discard();
+ return created;
+ }}
+ onClose={() => stitches.setPending(null)}
+ />
+ ) : null}
+ {planning ? (
+ setPlanning(null)}
+ onCommit={(next, newTab) => {
+ if (newTab) return onCreate(next);
+ onStrokeStart();
+ onChange(next);
+ return true;
+ }}
+ />
+ ) : null}
+ >
+ );
+}
diff --git a/components/yoke-gauge-panel.tsx b/components/yoke-gauge-panel.tsx
new file mode 100644
index 0000000..a912b89
--- /dev/null
+++ b/components/yoke-gauge-panel.tsx
@@ -0,0 +1,142 @@
+"use client";
+import { useState } from "react";
+import type { ChartDocument } from "@/lib/chart";
+import { readChartDocument } from "@/lib/chart-schema";
+import { convertYokeUnits, type YokeMeasurements } from "@/lib/yoke-plan";
+import { regaugeYoke, yokeDocument } from "@/lib/yoke-document";
+
+export function YokeGaugePanel({
+ document,
+ onChange,
+ onRecalculate,
+}: {
+ document: ChartDocument;
+ onChange: (document: ChartDocument) => void;
+ onRecalculate: (measurements: YokeMeasurements) => void;
+}) {
+ const project = document.yoke!;
+ const m = project.measurements;
+ const [draft, setDraft] = useState({
+ gaugeStitches: String(m.gaugeStitches),
+ gaugeRows: String(m.gaugeRows),
+ gaugeMeasure: String(m.gaugeMeasure),
+ });
+ const [error, setError] = useState("");
+ function read() {
+ const gauge = Object.fromEntries(
+ Object.entries(draft).map(([key, value]) => [
+ key,
+ value.trim() ? Number(value) : NaN,
+ ]),
+ ) as Pick;
+ const result = regaugeYoke(project, gauge);
+ readChartDocument(yokeDocument(result, document.name));
+ return { gauge, result };
+ }
+ return (
+
+
+ Gauge ⌄
+
+
+
+ {(
+ [
+ ["gaugeStitches", "Gauge stitches"],
+ ["gaugeRows", "Gauge rows"],
+ ] as const
+ ).map(([key, label]) => (
+
+ {label}
+
+ setDraft((old) => ({ ...old, [key]: event.target.value }))
+ }
+ />
+
+ ))}
+
+
+
+ Measured over
+
+ setDraft((old) => ({
+ ...old,
+ gaugeMeasure: event.target.value,
+ }))
+ }
+ />
+
+
+ Units
+ {
+ try {
+ onChange(
+ readChartDocument(
+ yokeDocument(
+ {
+ ...project,
+ measurements: convertYokeUnits(
+ read().result.measurements,
+ event.target.value as "cm" | "in",
+ ),
+ },
+ document.name,
+ ),
+ ),
+ );
+ } catch {
+ setError("The gauge could not be converted.");
+ }
+ }}
+ >
+ cm
+ inches
+
+
+
+
+ Update gauge keeps the stitches and re-measures the yoke.
+
+
{
+ try {
+ onChange(yokeDocument(read().result, document.name));
+ setError("");
+ } catch (error) {
+ setError(
+ error instanceof Error ? error.message : "Check the gauge.",
+ );
+ }
+ }}
+ >
+ Update gauge
+
+
{
+ try {
+ onRecalculate({ ...m, ...read().gauge });
+ setError("");
+ } catch {
+ setError("Complete the gauge first.");
+ }
+ }}
+ >
+ Recalculate shaping to keep measurements…
+
+ {error ?
{error}
: null}
+
+
+ );
+}
diff --git a/components/yoke-instructions-sheet.tsx b/components/yoke-instructions-sheet.tsx
new file mode 100644
index 0000000..aa2a08f
--- /dev/null
+++ b/components/yoke-instructions-sheet.tsx
@@ -0,0 +1,96 @@
+import type { ChartDocument } from "@/lib/chart";
+import { yokeWrittenRounds } from "@/lib/yoke-knit";
+import { planYokeShortRows, shortRowInstructions } from "@/lib/yoke-short-rows";
+
+export function YokeInstructionsSheet({
+ document,
+}: {
+ document: ChartDocument;
+}) {
+ const project = document.yoke!;
+ const { repeatCount, rounds } = yokeWrittenRounds(project);
+ const repeatInstruction =
+ repeatCount > 1
+ ? `Work each listed sequence ${repeatCount} times per round.`
+ : null;
+ let shortRows: string[] = [];
+ if (project.measurements.backRise) {
+ try {
+ const plan = planYokeShortRows(project.measurements, project.shortRows);
+ if (plan.extraRows) shortRows = shortRowInstructions(plan);
+ } catch {
+ shortRows = [
+ "Review the back-neck turn positions in Edit shaping before starting Round 1.",
+ ];
+ }
+ }
+ return (
+
+
+ Circular colourwork yoke
+ {document.name || "Untitled yoke"}
+
+ {rounds.length} rounds · {rounds[0].startingStitches} stitches at the
+ neck · {rounds.at(-1)!.endingStitches} stitches at the lower edge
+
+
+ Gauge: {document.gaugeStitches} stitches and {document.gaugeRows}{" "}
+ rounds over {document.gaugeMeasure} {document.gaugeUnit}.
+
+
+
+ Colour key
+
+ {project.palette.map((hex, i) => (
+
+
+ {i === 0 ? "MC" : `CC${i}`}
+ {hex.toUpperCase()}
+
+ ))}
+
+
+
+ How to work the yoke
+
+ Work in the round, starting at centre back. Follow the sequences in
+ the order written. Colour counts are ordinary knit stitches; shaping
+ and other stitch actions are marked separately. Use the main colour
+ for stitch actions unless a contrast colour is shown in parentheses.
+
+
+ {repeatInstruction ?? "Work each listed sequence once per round."}{" "}
+ Stitch totals are for the whole round.
+
+
+ {shortRows.length ? (
+
+ Back-neck short rows
+ {shortRows.map((line) => (
+ {line}
+ ))}
+
+ ) : null}
+
+ Written rounds
+ {repeatInstruction ? {repeatInstruction}
: null}
+
+ {rounds.map((round) => (
+
+ Round {round.number}
+ {round.sequence}
+
+ {round.startingStitches} sts in → {round.endingStitches} sts out{" "}
+ ({round.change > 0 ? "+" : ""}
+ {round.change})
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/components/yoke-knitted-preview.tsx b/components/yoke-knitted-preview.tsx
new file mode 100644
index 0000000..017ae9a
--- /dev/null
+++ b/components/yoke-knitted-preview.tsx
@@ -0,0 +1,111 @@
+"use client";
+import { useState, type RefObject } from "react";
+import type { ChartDocument } from "@/lib/chart";
+import type { YokePreviewView, YokeSweaterView } from "@/lib/yoke-preview-view";
+import { yokeRepeatSides } from "@/lib/yoke-repeat-labels";
+import { ConnectedKnitPreview } from "./connected-knit-preview";
+import { YokePhotoSample } from "./yoke-photo-sample";
+import {
+ fitsSweaterPhoto,
+ SWEATER_PREVIEW_TOO_DEEP,
+} from "@/lib/yoke-photo-preview";
+
+export function YokeKnittedPreview({
+ document,
+ canvasRef,
+ stale,
+ onRefresh,
+}: {
+ document: ChartDocument;
+ canvasRef: RefObject;
+ stale: boolean;
+ onRefresh: () => void;
+}) {
+ const [open, setOpen] = useState(true);
+ const [view, setView] = useState<
+ Exclude | "sweater"
+ >("whole");
+ const [repeat, setRepeat] = useState(0);
+ const repeatCount = document.yoke?.measurements.repeats ?? 1;
+ const selectedRepeat = Math.min(repeat, repeatCount - 1);
+ const sweaterUnavailable = view === "sweater" && !fitsSweaterPhoto(document);
+ return (
+ setOpen(event.currentTarget.open)}
+ >
+
+
+
Knitted up
+
+
+ Show / hide ⌄
+
+
+ {open ? (
+
+
+
+ View
+ setView(event.target.value as typeof view)}
+ >
+ Whole yoke
+ Front half
+ Back half
+ One repeat
+ Sweater
+
+
+ {view === "repeat" ? (
+
+ Preview repeat
+ setRepeat(Number(event.target.value))}
+ >
+ {yokeRepeatSides(repeatCount).map(({ name, repeats }) => (
+
+ {repeats.map(({ number, section }) => (
+
+ {number} · {name}
+
+ ))}
+
+ ))}
+
+
+ ) : null}
+
+ Refresh preview
+
+
+ {stale ? (
+
+ Preview needs a refresh.
+
+ ) : null}
+ {sweaterUnavailable ? (
+
{SWEATER_PREVIEW_TOO_DEEP}
+ ) : view === "sweater" ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+ );
+}
diff --git a/components/yoke-measurement-help.tsx b/components/yoke-measurement-help.tsx
new file mode 100644
index 0000000..ce306b1
--- /dev/null
+++ b/components/yoke-measurement-help.tsx
@@ -0,0 +1,356 @@
+"use client";
+import { useId, type ReactNode } from "react";
+import { yokeFieldInfo, type YokeField } from "@/lib/yoke-measurement-fields";
+import type { YokeMeasurementMode } from "@/lib/yoke-measurement-mode";
+import { MeasurementPoint as Point } from "./yoke-measurement-point";
+
+type Props = {
+ active: YokeField;
+ mode: YokeMeasurementMode;
+ open: boolean;
+ onOpen: (value: boolean) => void;
+ onSelect: (field: YokeField) => void;
+};
+function Line({
+ field,
+ active,
+ children,
+}: {
+ field: YokeField;
+ active: YokeField;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function YokeMeasurementHelp({
+ active,
+ mode,
+ open,
+ onOpen,
+ onSelect,
+}: Props) {
+ const id = useId().replaceAll(":", "");
+ const info = yokeFieldInfo(active, mode);
+ const sleeve = mode === "body" ? "sleeveEase" : "upperArm";
+ const chest = mode === "body" ? "bodyEase" : "chest";
+ const legend: { key: YokeField; text: string }[] = [
+ { key: "chest", text: "1 Chest / body" },
+ { key: "upperArm", text: "2 Arm / sleeve" },
+ { key: "neck", text: "3 Neck opening" },
+ { key: "depth", text: "4 Yoke length" },
+ { key: "underarm", text: "5 Underarm width" },
+ { key: "backRise", text: "6 Back height" },
+ { key: "shoulderCircumference", text: "7 Shoulder circumference" },
+ { key: "shoulderLength", text: "8 Shoulder length" },
+ { key: "gaugeMeasure", text: "Gauge" },
+ { key: "repeats", text: "Repeats" },
+ ];
+ return (
+ onOpen(event.currentTarget.open)}
+ >
+ How to take these measurements
+
+ Choose a numbered point, or focus a measurement field. Dashed lines show
+ the part of a circumference that wraps behind.
+
+
+
+ Body · measure without added room
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Chest / bust
+
+
+ Upper arm
+
+
+ Tape around, not across
+
+
+ {mode === "finished"
+ ? "Body reference · finished sizes use sweater"
+ : "Add desired room in the ease fields"}
+
+
+
+
+
+ Sweater · finished dimensions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1 & 2: full circumference
+
+
+ 4 & 8: along front fabric
+
+
+ 7: around torso + both sleeves
+
+
+
+
+
+ 5 · Underarm cast-on width
+
+
+
+
+
+
+
+
+
+ Measure the highlighted bridge at one underarm—not around the
+ armhole.
+
+
+
+ 6 · Extra back-neck height
+
+
+
+
+
+
+
+
+ Back view: extra height is added at centre back, tapering towards
+ both shoulders.
+
+
+
+
+
{info.label}
+
{info.help}
+
+
+ {legend.map((item) => (
+ onSelect(item.key)}
+ >
+ {item.text}
+
+ ))}
+
+
+ );
+}
diff --git a/components/yoke-measurement-point.tsx b/components/yoke-measurement-point.tsx
new file mode 100644
index 0000000..c7335af
--- /dev/null
+++ b/components/yoke-measurement-point.tsx
@@ -0,0 +1,48 @@
+import type { YokeField } from "@/lib/yoke-measurement-fields";
+
+export function MeasurementPoint({
+ x,
+ y,
+ number,
+ field,
+ active,
+ onSelect,
+ diagram,
+ disabled = false,
+}: {
+ x: number;
+ y: number;
+ number: number;
+ field: YokeField;
+ active: YokeField;
+ onSelect: (field: YokeField) => void;
+ diagram: string;
+ disabled?: boolean;
+}) {
+ return (
+ {
+ if (!disabled) onSelect(field);
+ }}
+ onKeyDown={(event) => {
+ if (!disabled && (event.key === "Enter" || event.key === " ")) {
+ event.preventDefault();
+ onSelect(field);
+ }
+ }}
+ >
+
+
+ {number}
+
+
+ );
+}
diff --git a/components/yoke-photo-sample.tsx b/components/yoke-photo-sample.tsx
new file mode 100644
index 0000000..a0320e6
--- /dev/null
+++ b/components/yoke-photo-sample.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { useEffect, useRef, useState, type RefObject } from "react";
+import type { ChartDocument } from "@/lib/chart";
+import { renderSweaterPhoto } from "@/lib/yoke-photo-preview";
+
+export function YokePhotoSample({
+ document,
+ canvasRef,
+}: {
+ document: ChartDocument;
+ canvasRef?: RefObject;
+}) {
+ const internalRef = useRef(null);
+ const canvas = canvasRef ?? internalRef;
+ const [failedDocument, setFailedDocument] = useState(
+ null,
+ );
+ useEffect(() => {
+ const image = new Image();
+ const reliefImage = new Image();
+ let cancelled = false;
+ if (canvas.current) canvas.current.dataset.previewState = "rendering";
+ image.src = "/images/yoke/photo-sweater-front.webp";
+ reliefImage.src = "/textures/merino-relief.png";
+ void Promise.all([image.decode(), reliefImage.decode()])
+ .then(() => {
+ const element = canvas.current;
+ if (cancelled || !element) return;
+ element.width = image.naturalWidth;
+ element.height = image.naturalHeight;
+ const context = element.getContext("2d");
+ if (!context) throw new Error("Canvas is unavailable.");
+ context.drawImage(image, 0, 0);
+ const photo = context.getImageData(0, 0, element.width, element.height);
+ context.clearRect(0, 0, element.width, element.height);
+ context.filter = "blur(9px)";
+ context.drawImage(image, 0, 0);
+ context.filter = "none";
+ const lighting = context.getImageData(
+ 0,
+ 0,
+ element.width,
+ element.height,
+ );
+ const relief = window.document.createElement("canvas");
+ relief.width = reliefImage.naturalWidth;
+ relief.height = reliefImage.naturalHeight;
+ const texture = relief.getContext("2d")!;
+ texture.drawImage(reliefImage, 0, 0);
+ renderSweaterPhoto(
+ photo,
+ lighting.data,
+ texture.getImageData(0, 0, relief.width, relief.height),
+ document,
+ );
+ context.putImageData(photo, 0, 0);
+ element.dataset.ready = "true";
+ element.dataset.previewState = "ready";
+ })
+ .catch(() => {
+ if (!cancelled) {
+ if (canvas.current) canvas.current.dataset.previewState = "error";
+ setFailedDocument(document);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [document, canvas]);
+ return (
+ <>
+ {failedDocument === document ? (
+ Could not render the sweater photo.
+ ) : null}
+
+ >
+ );
+}
diff --git a/components/yoke-plan-dialog.tsx b/components/yoke-plan-dialog.tsx
new file mode 100644
index 0000000..a7ed7c5
--- /dev/null
+++ b/components/yoke-plan-dialog.tsx
@@ -0,0 +1,268 @@
+"use client";
+import { useId, useState } from "react";
+import type { ChartDocument } from "@/lib/chart";
+import { readChartDocument } from "@/lib/chart-schema";
+import {
+ defaultYokeMeasurements,
+ calculateYokeTargets,
+ suggestYokeSchedule,
+ type YokeMeasurements,
+ type IncreaseEvent,
+} from "@/lib/yoke-plan";
+import { createYokeProject } from "@/lib/yoke-project";
+import {
+ reshapeYoke,
+ yokeDocument,
+ yokeTransferCounts,
+} from "@/lib/yoke-document";
+import { YokePlanner } from "./yoke-planner";
+import { YokeMeasurementHelp } from "./yoke-measurement-help";
+import { YokeCanvas } from "./yoke-canvas";
+import { YokeShortRowPlanner } from "./yoke-short-row-planner";
+import type { YokeField } from "@/lib/yoke-measurement-fields";
+import type { YokeMeasurementMode } from "@/lib/yoke-measurement-mode";
+import type { YokeShortRows } from "@/lib/yoke-short-rows";
+
+export function YokePlanDialog({
+ document,
+ initialMeasurements,
+ tabsFull,
+ onCommit,
+ onClose,
+}: {
+ document?: ChartDocument;
+ initialMeasurements?: YokeMeasurements;
+ tabsFull: boolean;
+ onCommit: (document: ChartDocument, newTab: boolean) => boolean;
+ onClose: () => void;
+}) {
+ const [base] = useState(() => document);
+ const measurements =
+ initialMeasurements ?? base?.yoke?.measurements ?? defaultYokeMeasurements;
+ const events =
+ base?.yoke?.events ??
+ suggestYokeSchedule(
+ calculateYokeTargets(measurements),
+ measurements.repeats,
+ );
+ const [pending, setPending] = useState<{
+ measurements: YokeMeasurements;
+ events: IncreaseEvent[];
+ } | null>(null);
+ const [mode, setMode] = useState<"keep" | "scale">("keep");
+ const [shortRows, setShortRows] = useState();
+ const [error, setError] = useState("");
+ const [guide, setGuide] = useState<{
+ field: YokeField;
+ mode: YokeMeasurementMode;
+ }>({ field: "chest", mode: measurements.measurementMode });
+ const [guideOpen, setGuideOpen] = useState(false);
+ const id = useId();
+ const proposal = pending
+ ? base?.yoke
+ ? reshapeYoke(base.yoke, pending.measurements, pending.events, mode)
+ : createYokeProject(pending.measurements, pending.events)
+ : null;
+ if (proposal && shortRows) proposal.shortRows = shortRows;
+ const structural =
+ !base?.yoke ||
+ !!base.yoke.program ||
+ (!!proposal &&
+ (proposal.measurements.repeats !== base.yoke.measurements.repeats ||
+ JSON.stringify(proposal.cells.map((row) => row.length)) !==
+ JSON.stringify(base.yoke.cells.map((row) => row.length))));
+ const transfer =
+ base?.yoke && proposal ? yokeTransferCounts(base.yoke, proposal) : null;
+ function commit() {
+ try {
+ if (!proposal) return;
+ if (base && JSON.stringify(base) !== JSON.stringify(document))
+ throw new Error(
+ "The chart changed while planning. Close and reopen Edit shaping before applying.",
+ );
+ const next = readChartDocument(
+ yokeDocument(
+ proposal,
+ base
+ ? `${base.name.slice(0, structural ? 192 : 200)}${structural ? " revised" : ""}`
+ : "Untitled yoke",
+ ),
+ );
+ if (onCommit(next, structural)) onClose();
+ else
+ setError(
+ "Close a chart tab before creating this yoke. The original is unchanged.",
+ );
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : "The yoke could not be applied.",
+ );
+ }
+ }
+ return (
+
+
+
+
+ {base ? "Edit yoke shaping" : "Create circular yoke"}
+
+
+ ×
+
+
+ {error ? {error}
: null}
+ {!pending ? (
+ <>
+ {base?.yoke?.program ? (
+
+ Replanning replaces hand-edited stitch instructions in a new
+ copy. Your current yoke is kept.
+
+ ) : null}
+ {
+ setPending({ measurements, events });
+ setShortRows(undefined);
+ setError("");
+ }}
+ onMeasurementFocus={(field, mode) => setGuide({ field, mode })}
+ measurementHelp={
+
+ window.document.getElementById(`${id}-${field}`)?.focus()
+ }
+ />
+ }
+ applyLabel="Preview yoke"
+ />
+ >
+ ) : proposal ? (
+ <>
+ {base ? (
+
+ Carry over the colourwork
+
+ setMode("keep")}
+ />
+ Keep stitch size
+
+
+ setMode("scale")}
+ />
+ Scale design to fit
+
+
+ Each repeat stays separate. Keep stitch size centres motifs
+ within their repeat and keeps round 1 at the neck. Added space
+ and extra repeats use the main colour. Shaping stitches come
+ from the new plan.
+
+
+ ) : null}
+ {base && transfer ? (
+
+ {mode === "keep"
+ ? `${transfer.cropped} original stitches outside the new shape; ${transfer.added} new stitches filled with the main colour.`
+ : "Colourwork is resampled per repeat to the new stitch and round counts."}
+
+ ) : null}
+
+ {base?.yoke ? (
+
+ Original · unchanged
+ {}}
+ onStrokeStart={() => {}}
+ />
+
+ ) : null}
+
+ {base ? "Proposed" : "Your yoke"}
+ {}}
+ onStrokeStart={() => {}}
+ />
+
+
+
+ Neckline short rows
+
+
+
+ {structural && base
+ ? "The revised yoke opens in a new tab. Your original stays open."
+ : base
+ ? "The colourwork is kept. This change can be undone."
+ : "Your yoke opens in a new chart tab."}
+
+ {tabsFull && structural ? (
+
+ All eight chart tabs are in use. Close one before creating a
+ yoke.
+
+ ) : null}
+
+ setPending(null)}
+ >
+ Back to plan
+
+
+ {base
+ ? structural
+ ? "Create revised yoke"
+ : "Apply shaping"
+ : "Create yoke"}
+
+
+ >
+ ) : null}
+
+
+ );
+}
diff --git a/components/yoke-planner.tsx b/components/yoke-planner.tsx
index cb08b43..cafab50 100644
--- a/components/yoke-planner.tsx
+++ b/components/yoke-planner.tsx
@@ -1,119 +1,132 @@
"use client";
-import { useState, type FormEvent } from "react";
+import { useMemo, useState, type FormEvent, type ReactNode } from "react";
import { ZodError } from "zod";
+import { MAX_GAUGE_VALUE } from "@/lib/gauge-schema";
import {
calculateYokeTargets,
convertYokeUnits,
suggestYokeSchedule,
+ parseIncreasePositions,
yokeRoundCounts,
type YokeMeasurements,
type IncreaseEvent,
type YokePlan,
} from "@/lib/yoke-plan";
+import { YokeScheduleEditor, scheduleDrafts } from "./yoke-schedule-editor";
+import {
+ measurementDrafts as drafts,
+ readMeasurementDrafts as read,
+ yokeMeasurementFields,
+ type YokeField,
+} from "@/lib/yoke-measurement-fields";
+import {
+ switchYokeMeasurementMode,
+ type YokeMeasurementMode,
+} from "@/lib/yoke-measurement-mode";
+import { yokeRoundingReview } from "@/lib/yoke-rounding";
+import { YokeRoundingReview } from "./yoke-rounding-review";
-type NumberKey = Exclude;
-const fields: { key: NumberKey; label: string; help?: string }[] = [
- { key: "gaugeStitches", label: "Gauge stitches" },
- { key: "gaugeRows", label: "Gauge rows" },
- { key: "gaugeMeasure", label: "Gauge measured over" },
- {
- key: "neck",
- label: "Finished neck opening",
- help: "Circumference at the base of the neckband; neckband not included.",
- },
- { key: "chest", label: "Body chest / bust" },
- { key: "bodyEase", label: "Body ease" },
- { key: "upperArm", label: "Body upper arm" },
- { key: "sleeveEase", label: "Sleeve ease" },
- {
- key: "underarm",
- label: "Each underarm width",
- help: "Width cast on at each underarm when separating sleeves.",
- },
- {
- key: "depth",
- label: "Full-round yoke length",
- help: "Along the fabric from the base of the front neckline to the sleeve split. Not vertical armhole depth; excludes extra back short rows.",
- },
- {
- key: "backRise",
- label: "Extra back-neck height",
- help: "Optional plain-colour short rows before the motif. Enter 0 to omit.",
- },
- { key: "repeats", label: "Repeats around yoke" },
-];
-function drafts(measurements: YokeMeasurements) {
- return Object.fromEntries(
- fields.map(({ key }) => [key, String(measurements[key])]),
- ) as Record;
-}
-function read(
- values: Record,
- unit: "cm" | "in",
-): YokeMeasurements {
- if (Object.values(values).some((value) => value.trim() === ""))
- throw new Error(
- "Fill in every measurement, using 0 for no ease or no short rows.",
- );
- return {
- ...Object.fromEntries(
- Object.entries(values).map(([key, value]) => [key, Number(value)]),
- ),
- unit,
- } as YokeMeasurements;
-}
type Candidate = {
measurements: YokeMeasurements;
targets: YokePlan;
source: string;
};
type Props = {
+ applyLabel?: string;
measurements: YokeMeasurements;
events: IncreaseEvent[];
onApply: (measurements: YokeMeasurements, events: IncreaseEvent[]) => void;
+ inputId: string;
+ measurementHelp: ReactNode;
+ onMeasurementFocus: (field: YokeField, mode: YokeMeasurementMode) => void;
};
-export function YokePlanner({ measurements, events, onApply }: Props) {
+export function YokePlanner({
+ applyLabel = "Apply plan to drawing",
+ measurements,
+ events,
+ onApply,
+ inputId,
+ measurementHelp,
+ onMeasurementFocus,
+}: Props) {
const [unit, setUnit] = useState(measurements.unit);
+ const [mode, setMode] = useState(
+ measurements.measurementMode ?? "body",
+ );
const [values, setValues] = useState(() => drafts(measurements));
const [candidate, setCandidate] = useState(null);
- const [schedule, setSchedule] = useState(
- events.map((event) => ({
- round: String(event.round),
- count: String(event.count),
- })),
- );
+ const [schedule, setSchedule] = useState(scheduleDrafts(events));
+ const [increaseRounds, setIncreaseRounds] = useState(String(events.length));
+ const [automatic, setAutomatic] = useState(true);
const [error, setError] = useState("");
- const source = JSON.stringify({ values, unit });
+ const [roundingAccepted, setRoundingAccepted] = useState(false);
+ const rounding = useMemo(
+ () =>
+ candidate
+ ? yokeRoundingReview(candidate.measurements, candidate.targets)
+ : null,
+ [candidate],
+ );
+ const source = JSON.stringify({
+ values,
+ unit,
+ mode,
+ automatic,
+ increaseRounds,
+ });
+ const fields = yokeMeasurementFields(mode);
const dirty = candidate !== null && candidate.source !== source;
const number = (value: number) => value.toFixed(1);
function calculate(event: FormEvent) {
event.preventDefault();
try {
- const next = read(values, unit);
+ const next = read(values, unit, mode);
const targets = calculateYokeTargets(next);
- setCandidate({ measurements: next, targets, source });
- setSchedule(
- suggestYokeSchedule(targets, next.repeats).map((entry) => ({
- round: String(entry.round),
- count: String(entry.count),
- })),
+ const requested = Number(increaseRounds);
+ const maximum = Math.min(
+ targets.rounds - 1,
+ (targets.end - targets.neck) / next.repeats,
);
+ const minimum = Math.ceil(Math.log2(targets.end / targets.neck));
+ if (
+ !automatic &&
+ (!Number.isInteger(requested) ||
+ requested < minimum ||
+ requested > maximum)
+ )
+ throw new Error(
+ `Choose ${minimum}–${maximum} increase rounds for these targets.`,
+ );
+ const nextSchedule = scheduleDrafts(
+ suggestYokeSchedule(
+ targets,
+ next.repeats,
+ automatic ? undefined : requested,
+ ),
+ );
+ setCandidate({ measurements: next, targets, source });
+ setSchedule(nextSchedule);
+ setRoundingAccepted(false);
setError("");
} catch (error) {
+ setCandidate(null);
setError(
error instanceof Error && !(error instanceof ZodError)
? error.message
- : "Check the measurements: use finite numbers, positive gauge and lengths, and 2–40 whole repeats.",
+ : `Check the measurements: gauge values must be greater than 0 and at most ${MAX_GAUGE_VALUE}, lengths must be positive, and repeats must be 2–40 whole numbers.`,
);
}
}
function apply() {
- if (!candidate || dirty) return;
+ if (!candidate || dirty || (rounding?.changes.length && !roundingAccepted))
+ return;
try {
const nextEvents = schedule.map((event) => ({
round: Number(event.round),
count: Number(event.count),
+ stitch: event.stitch,
+ ...parseIncreasePositions(event.positions, event.stitch),
}));
yokeRoundCounts(
candidate.targets,
@@ -136,50 +149,136 @@ export function YokePlanner({ measurements, events, onApply }: Props) {
Top-down pullover · one consistent blocked gauge
-