From 3298c68ffa10b288da6fb0e324c890279e2a3d7a Mon Sep 17 00:00:00 2001 From: syalr2 Date: Wed, 11 Nov 2026 21:24:53 -0500 Subject: [PATCH] Support 300 rows in regular charts --- README.md | 2 +- e2e/editor.spec.ts | 8 ++-- lib/ai-request.test.ts | 6 +-- lib/chart-schema.test.ts | 84 ++++++++++++++++++++++++++++++++++++- lib/chart.ts | 2 +- lib/project-file.ts | 21 +++++++++- lib/yoke-validation.test.ts | 13 ++++++ 7 files changed, 125 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7e367a4..a1c6f5f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Open the hosted chart maker at [knitplot.art](https://knitplot.art). Every non-A - Draw, erase, flood-fill, and fill rectangular selections - Choose up to eight custom colours -- Work with charts up to 200 stitches by 150 rows +- Work with regular charts up to 200 stitches by 300 rows - Set stitch gauge, row gauge, and centimetres or inches - Zoom the editable chart and use optional live-symmetry guides - Flip, rotate, undo, and redo chart changes diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index c5fa2d9..451790e 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -140,17 +140,17 @@ test("chart dimensions allow typing intermediate values and reset on tab changes }); for (const structural of [false, true]) { - test(`stitch bitmap exports exact yarn pixels${structural ? " with explicit no-stitch handling" : " at 114 × 150"}`, async ({ + test(`stitch bitmap exports exact yarn pixels${structural ? " with explicit no-stitch handling" : " at 114 × 300"}`, async ({ page, }) => { const doc = cloneDocument(defaultDocument); doc.width = 114; - doc.height = 150; + doc.height = structural ? 150 : 300; doc.palette = [ { id: "pink", hex: "#ff809a" }, { id: "green", hex: "#123456" }, ]; - doc.cells = Array.from({ length: 150 }, (_, row) => + doc.cells = Array.from({ length: doc.height }, (_, row) => Array.from({ length: 114 }, (_, column) => (row + column) % 3 === 0 ? "pink" : "green", ), @@ -243,7 +243,7 @@ for (const structural of [false, true]) { ); expect(result).toEqual({ width: 114, - height: 150, + height: doc.height, mismatches: 0, colours: 2, }); diff --git a/lib/ai-request.test.ts b/lib/ai-request.test.ts index 2c5fd9f..c87c745 100644 --- a/lib/ai-request.test.ts +++ b/lib/ai-request.test.ts @@ -17,9 +17,9 @@ test("AI generation uses the user's dimensions, including large charts", () => { readAiRequest(generateRequestSchema, { ...settings, width: 200, - height: 150, - }).width, - 200, + height: 300, + }).height, + 300, ); }); test("both AI routes reject null and invalid colour ranges", () => { diff --git a/lib/chart-schema.test.ts b/lib/chart-schema.test.ts index b4186b1..d5bbd7f 100644 --- a/lib/chart-schema.test.ts +++ b/lib/chart-schema.test.ts @@ -2,10 +2,59 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { cloneDocument, defaultDocument } from "./chart"; import { readProject } from "./project-file"; -import { readCloudChartPayload } from "./cloud-charts"; +import { readCloudChartPayload, readCloudChartRequest } from "./cloud-charts"; import { readChartDocument } from "./chart-schema"; +import { newChartTab, readWorkspace, workspaceSave } from "./workspace"; const document = () => cloneDocument(defaultDocument); +test("a tall imported chart with long palette IDs fits the cloud request budget", async () => { + const longId = "a".repeat(50); + const otherId = "yarn-0"; + const grid = (value: T) => + Array.from({ length: 300 }, () => Array(200).fill(value)); + const chart = cloneDocument({ + ...defaultDocument, + width: 200, + height: 300, + palette: [ + { id: longId, hex: "#aabbcc" }, + { id: otherId, hex: "#ffffff" }, + ], + cells: grid(longId), + stitchActions: grid("purl"), + stitchCellColors: grid("#aabbcc"), + }); + chart.cells[299][199] = otherId; + const request = (doc: typeof chart) => + new Request("http://localhost/api/charts", { + method: "POST", + body: JSON.stringify({ document: doc }), + }); + await assert.rejects(readCloudChartRequest(request(chart)), { + status: 413, + }); + const opened = readProject({ + format: "colorwork-chart", + version: 2, + document: chart, + }); + const saved = readCloudChartPayload( + await readCloudChartRequest(request(opened)), + ).document; + assert.deepEqual( + saved.palette.map(({ hex }) => hex), + ["#aabbcc", "#ffffff"], + ); + assert.deepEqual( + saved.cells, + chart.cells.map((row) => + row.map((id) => saved.palette[id === longId ? 0 : 1].id), + ), + ); + assert.deepEqual(saved.stitchActions, chart.stitchActions); + assert.deepEqual(saved.stitchCellColors, chart.stitchCellColors); +}); + test("project and cloud round trips preserve a chart", () => { const chart = document(); assert.deepEqual( @@ -14,6 +63,39 @@ test("project and cloud round trips preserve a chart", () => { ); assert.deepEqual(readCloudChartPayload({ document: chart }).document, chart); }); +test("a 200 by 300 regular chart and last-row progress survive storage round trips", () => { + const chart = cloneDocument({ + ...defaultDocument, + width: 200, + height: 300, + cells: Array.from({ length: 300 }, () => + Array(200).fill("background"), + ), + }); + const tab = newChartTab(chart, "tall-chart"); + tab.knitProgress.row = 300; + assert.deepEqual( + readProject({ format: "colorwork-chart", version: 2, document: chart }), + chart, + ); + assert.deepEqual(readCloudChartPayload(tab).document, chart); + assert.equal(readCloudChartPayload(tab).knitProgress.row, 300); + const reopened = readWorkspace( + JSON.parse(JSON.stringify(workspaceSave([tab], tab.id))), + ).workspace.tabs[0]; + assert.deepEqual(reopened.document, chart); + assert.equal(reopened.knitProgress.row, 300); + assert.throws(() => + readChartDocument( + cloneDocument({ + ...defaultDocument, + width: chart.width, + height: 301, + cells: [...chart.cells, chart.cells[0]], + }), + ), + ); +}); for (const [name, mutate] of Object.entries({ "unknown action": (c: Record) => { c.stitchActions = Array.from({ length: defaultDocument.height }, () => diff --git a/lib/chart.ts b/lib/chart.ts index 5fffa2e..3aeaf6a 100644 --- a/lib/chart.ts +++ b/lib/chart.ts @@ -1,7 +1,7 @@ export type Tool = "pencil" | "fill" | "eraser" | "select"; export const MAX_CHART_STITCHES = 200; -export const MAX_CHART_ROWS = 150; +export const MAX_CHART_ROWS = 300; export type CellSelection = { startRow: number; diff --git a/lib/project-file.ts b/lib/project-file.ts index ec3c5e2..e88b4e5 100644 --- a/lib/project-file.ts +++ b/lib/project-file.ts @@ -13,5 +13,24 @@ export function readProject(value: unknown) { ) { throw new HttpError("This is not a KnitPlot file."); } - return readChartDocument(value.document); + const document = readChartDocument(value.document); + if ( + document.yoke || + document.palette.every(({ id }) => /^[a-z0-9_-]{1,20}$/i.test(id)) + ) + return document; + + // IDs are internal and repeated in every cell. Compact the whole palette + // together so large imported charts fit within the cloud request budget. + const ids = new Map( + document.palette.map(({ id }, index) => [id, `yarn-${index}`]), + ); + return { + ...document, + palette: document.palette.map((colour) => ({ + ...colour, + id: ids.get(colour.id)!, + })), + cells: document.cells.map((row) => row.map((id) => ids.get(id)!)), + }; } diff --git a/lib/yoke-validation.test.ts b/lib/yoke-validation.test.ts index f1517aa..67c0184 100644 --- a/lib/yoke-validation.test.ts +++ b/lib/yoke-validation.test.ts @@ -6,6 +6,19 @@ import { suggestIncreasePositions, } from "./yoke-plan"; +test("the yoke planner still rejects more than 150 rounds", () => { + assert.throws( + () => + calculateYokeTargets({ + ...defaultYokeMeasurements, + depth: + (151 * defaultYokeMeasurements.gaugeMeasure) / + defaultYokeMeasurements.gaugeRows, + }), + /150 full rounds/, + ); +}); + test("repeat rounding cannot erase the body portion at the sleeve split", () => { assert.throws( () =>