feat: feel cargo doc

This commit is contained in:
Oleg Proskurin 2026-04-23 16:58:11 +07:00
parent 3e36cfcb39
commit df04cb1d09
27 changed files with 2500 additions and 2 deletions

View File

@ -47,7 +47,8 @@
"Bash(mv freighter-science1.png freighter12.png)",
"Bash(sort -t'\\(' -k2 -n)",
"Bash(grep -o 'scale\\(3.50\\).\\\\{0,200\\\\}')",
"Bash(python3 -c ':*)"
"Bash(python3 -c ':*)",
"Bash(grep -r \"margin.*-\\\\|negative\" tasks/*/CLAUDE.md)"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -11,7 +11,8 @@
"pdf": "node src/scripts/generate-pdf.mjs",
"dev": "concurrently \"pnpm build:css:watch\" \"pnpm preview\"",
"split-sprites": "node src/scripts/split-sprites.mjs",
"remove-bg": "node src/scripts/remove-bg.mjs"
"remove-bg": "node src/scripts/remove-bg.mjs",
"create-shapes": "node src/scripts/create-asteroid-shapes.mjs"
},
"keywords": [],
"author": "",

View File

@ -0,0 +1,159 @@
import sharp from 'sharp';
import { resolve, basename, join } from 'path';
import { existsSync, mkdirSync, readdirSync } from 'fs';
import { fileURLToPath } from 'url';
const PROJECT_ROOT = resolve(fileURLToPath(import.meta.url), '../../..');
const DEFAULT_INPUT = resolve(PROJECT_ROOT, 'assets/icons/pack3-asteroids');
const DEFAULT_OUTPUT = resolve(PROJECT_ROOT, 'assets/items/asteroid-shapes');
// Tunable constants
const OUTLINE_WIDTH = 5; // px thickness of boundary outline
const OUTLINE_COLOR = 40; // darkness of outline (0=black, 255=white)
const EDGE_THRESHOLD = 30; // min gradient magnitude to count as an edge
const EDGE_DARKNESS = 180; // how dark internal edge lines are (0=black, 255=white)
const ALPHA_THRESHOLD = 128; // min alpha to consider a pixel "opaque"
async function createAsteroidShape(filePath, outputDir) {
// Step 1: Read source raw RGBA
const { data: srcData, info } = await sharp(filePath)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const { width, height } = info;
// Step 2: Gradient-based edge detection on greyscale
const greyResult = await sharp(filePath)
.greyscale()
.blur(1.0)
.raw()
.toBuffer({ resolveWithObject: true });
const grey = greyResult.data;
// Compute Sobel gradient magnitude per pixel
const gradients = new Float32Array(width * height);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
// Only compute for opaque pixels
if (srcData[idx * 4 + 3] < ALPHA_THRESHOLD) continue;
// Sobel X
const gx =
-grey[(y - 1) * width + (x - 1)] + grey[(y - 1) * width + (x + 1)]
- 2 * grey[y * width + (x - 1)] + 2 * grey[y * width + (x + 1)]
- grey[(y + 1) * width + (x - 1)] + grey[(y + 1) * width + (x + 1)];
// Sobel Y
const gy =
-grey[(y - 1) * width + (x - 1)] - 2 * grey[(y - 1) * width + x] - grey[(y - 1) * width + (x + 1)]
+ grey[(y + 1) * width + (x - 1)] + 2 * grey[(y + 1) * width + x] + grey[(y + 1) * width + (x + 1)];
gradients[idx] = Math.sqrt(gx * gx + gy * gy);
}
}
// Build base layer: white with faint gray edge lines where gradient is strong
const baseLayer = Buffer.alloc(width * height * 4);
for (let i = 0; i < width * height; i++) {
const alpha = srcData[i * 4 + 3];
if (alpha < ALPHA_THRESHOLD) {
// Transparent pixel — keep transparent
baseLayer[i * 4 + 3] = 0;
continue;
}
const grad = gradients[i];
let v = 255; // default white
if (grad > EDGE_THRESHOLD) {
// Map gradient above threshold to gray line darkness
const intensity = Math.min(1, (grad - EDGE_THRESHOLD) / 200);
v = 255 - Math.round(intensity * (255 - EDGE_DARKNESS));
}
baseLayer[i * 4 + 0] = v;
baseLayer[i * 4 + 1] = v;
baseLayer[i * 4 + 2] = v;
baseLayer[i * 4 + 3] = alpha;
}
// Step 3: Outline layer — boundary detection via neighbor check
const outlineLayer = Buffer.alloc(width * height * 4, 0);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (srcData[idx * 4 + 3] < ALPHA_THRESHOLD) continue;
let isBorder = false;
outer:
for (let dy = -OUTLINE_WIDTH; dy <= OUTLINE_WIDTH; dy++) {
for (let dx = -OUTLINE_WIDTH; dx <= OUTLINE_WIDTH; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || nx >= width || ny < 0 || ny >= height) {
isBorder = true;
break outer;
}
const nIdx = ny * width + nx;
if (srcData[nIdx * 4 + 3] < ALPHA_THRESHOLD) {
isBorder = true;
break outer;
}
}
}
if (isBorder) {
outlineLayer[idx * 4 + 0] = OUTLINE_COLOR;
outlineLayer[idx * 4 + 1] = OUTLINE_COLOR;
outlineLayer[idx * 4 + 2] = OUTLINE_COLOR;
outlineLayer[idx * 4 + 3] = 255;
}
}
}
// Step 4: Composite — base (white + faint edges) + outline on top
const raw = { width, height, channels: 4 };
await sharp(baseLayer, { raw })
.composite([
{
input: outlineLayer,
raw,
blend: 'over',
},
])
.png()
.toFile(join(outputDir, basename(filePath)));
}
async function main() {
const inputDir = resolve(process.argv[2] || DEFAULT_INPUT);
const outputDir = resolve(process.argv[3] || DEFAULT_OUTPUT);
if (!existsSync(inputDir)) {
console.error(`Not found: ${inputDir}`);
process.exit(1);
}
mkdirSync(outputDir, { recursive: true });
const files = readdirSync(inputDir)
.filter(f => /\.png$/i.test(f))
.sort()
.map(f => join(inputDir, f));
if (files.length === 0) {
console.error(`No PNG files found in: ${inputDir}`);
process.exit(1);
}
console.log(`Creating ${files.length} asteroid shape(s)...`);
await Promise.all(
files.map(async (filePath) => {
await createAsteroidShape(filePath, outputDir);
console.log(` ${basename(filePath)} -> done`);
})
);
console.log(`Done. ${files.length} shapes saved to ${outputDir}`);
}
main();

View File

@ -0,0 +1,124 @@
# Cargo Filling Task Type
Split an asteroid to fill a spaceship cargo bay to exactly 10. Combines asteroid splitting with cargo bay loading. Teaches addition crossing the tens boundary.
## Math Concept
Given:
- Cargo bay capacity: always **10**
- Asteroid A already in cargo (1-9)
- Asteroid B floating in space, B > (10 - A), so it doesn't fit whole
- Child splits B = N + M where A + N = 10 (N fills remaining space)
- Full expression: `A + B = (A + N) + M = C`
- Under `(A + N)` a curly brace with "10"
Answer is always: N = 10 - A, M = B - N, C = A + B.
## Layout
- **Page:** A4 (210mm x 297mm), white background
- **Header:** Hero splitter image + title + subtitle
- **Footer:** Cabin interior (28mm, `object-cover`)
- **Content:** 6 task cards per page in 2 columns x 3 rows grid
- **Dividers:** Dark solid lines between cards (`border-bottom: 1.5px solid #334155`, odd cards have `border-right`)
- **Card overflow:** `hidden` — ships get cropped at card boundary
## Card Layout (tuned values)
Each card has two zones:
- **Visual area** (flex: 1, relative) — asteroid, ship, arrows, remnant
- **Formula area** (flex-shrink: 0, bottom) — label + formula + brace
### Element positions & sizes (CSS)
| Element | Position | Size |
|---------|----------|------|
| `.space-asteroid` | `left: -7.5mm; top: 18.5mm` | `28mm × 28mm` |
| `.cargo-ship` | `right: -12.5mm; top: -5mm` | `90mm × 42mm` |
| `.badge-10` | `left: 68%; top: 39%` (of ship) | `22px × 22px` |
| `.inner-ast` | `left: 86%; top: 73%` (of ship) | `14mm × 14mm` |
| `.remnant-shape` | `left: 61%; bottom: 3mm` | `16mm × 16mm` shape |
### SVG arrows (viewBox `0 0 100 55`)
Both arrows start from asteroid center and curve outward:
- **Arrow to ship:** `M7,30 Q30,15 52,25`
- **Arrow to remnant:** `M7,30 Q24,57 61,47`
Stroke: `#6366f1`, width `0.5`, marker-end arrowhead.
### Visual elements
- **Big asteroid:** `pack3-asteroids/asteroidN.png` — colored, with white number B overlay (1.8rem)
- **Ship:** `pack4-cargobay/cargo-bayN.png` — flipped with `scaleX(-1)` so nose points right
- **Inner asteroid:** `items/asteroid-shapes/asteroidN.png` — line-art outline, white number A (1.1rem)
- **Remnant:** `items/asteroid-shapes/asteroidN.png` — line-art outline (opacity 0.5), blank underline, "?" label
- **Badge "10":** indigo circle (`#4f46e5`) with white "10"
- **Asteroid z-index: 6** (above arrows z-index: 4)
### Formula
```
запиши формулу для пилота
A + B = (A + __) + __ = __
10
```
- Label: uppercase, `#818cf8`, 0.45rem
- Formula: single line, 0.85rem bold `#1e1b4b`
- Brace: SVG path (width 90% of brace-group), margin-top 1.5mm
- "10": `#6366f1`, 0.6rem bold
- Brace gap from formula: 2mm (configurable via `braceGapMm`)
## Asset Conventions
- **Cargo bays:** `assets/icons/pack4-cargobay/cargo-bay{1-9}.png` — 6 unique per page
- **Asteroids (colored):** `assets/icons/pack3-asteroids/asteroid{1-16}.png` — for big asteroid in space
- **Asteroids (outline):** `assets/items/asteroid-shapes/asteroid{1-16}.png` — for inner asteroid & remnant
- **Hero images:** `assets/hero-images/splitters/splitter{1-9}.png`
- **Footer images:** `assets/footers/cabin{1-9}.jpeg`
## Problem Range
- A: 2-9 (value already in cargo)
- B: must be > (10-A) and ≤ 9
- Practical combinations:
- A=8,9: B from 3-9 (easiest — small split)
- A=5,6,7: B from 4-9 (medium)
- A=2,3,4: B from 7-9 (hardest — large split)
## Difficulty Progression
- Page 1: A=8,9 with smaller B values (easy)
- Page 2: A=5,6,7 (medium)
- Page 3: A=2,3,4 with larger B (hard)
## Color Palette
Same as project-wide indigo theme:
- Title: `text-indigo-950`
- Subtitle: `text-indigo-400`
- Number overlay (big asteroid): white with text-shadow
- Number overlay (inner asteroid): white with text-shadow
- Cargo "10" badge: `bg-indigo-600` circle with white text
- Formula text: `text-indigo-950`
- Input blanks: `border-indigo-300` underlines
- Brace + "10" label: `#6366f1`
- Arrows: `#6366f1`
## Title & Subtitle
- **Title:** "Заполни Трюм"
- **Subtitle:** "На какие части нужно расщепить астероид, чтобы заполнить свободное место в трюме? Какой кусочек останется в космосе? Каков общий объём?"
## Scripts
- `scripts/generate.mjs` — template + data → output pipeline (standard, no transforms yet)
## Editor
- `editor-temp.html` — temporary drag-and-drop tuner for card element positions/sizes
- Loads/saves state from `docs/cargo-filling-1.data.json`
- Export button outputs JSON with CSS values and SVG arrow paths
- Convention: edit only card 1, then apply values to all cards in template

View File

@ -0,0 +1,31 @@
{
"pages": [
{
"page": 1,
"cards": [
{
"index": 0,
"asteroidScale": 0.85,
"ship": {
"top": -7
},
"innerAst": {
"left": 57,
"top": 70
}
},
{
"index": 1,
"ship": {
"right": -26.5,
"top": -7
},
"innerAst": {
"left": 40,
"top": 68
}
}
]
}
]
}

View File

@ -0,0 +1,55 @@
# Cargo Filling — Document 1
Addition crossing the tens boundary. 3 pages, 6 cards per page, 18 problems total.
Difficulty progression: easy → medium → hard.
## Page 1 — Easy (A=8,9)
Hero: splitter3 (right), Footer: cabin1
| # | A | B | N=10-A | M=B-N | C=A+B | Asteroid img | Cargo bay img |
|---|---|---|--------|-------|-------|--------------|---------------|
| 1 | 8 | 3 | 2 | 1 | 11 | asteroid1 | cargo-bay1 |
| 2 | 9 | 4 | 1 | 3 | 13 | asteroid2 | cargo-bay2 |
| 3 | 8 | 5 | 2 | 3 | 13 | asteroid3 | cargo-bay3 |
| 4 | 9 | 6 | 1 | 5 | 15 | asteroid4 | cargo-bay1 |
| 5 | 8 | 7 | 2 | 5 | 15 | asteroid5 | cargo-bay2 |
| 6 | 9 | 8 | 1 | 7 | 17 | asteroid6 | cargo-bay3 |
## Page 2 — Medium (A=5,6,7)
Hero: splitter6 (left), Footer: cabin3
| # | A | B | N=10-A | M=B-N | C=A+B | Asteroid img | Cargo bay img |
|---|---|---|--------|-------|-------|--------------|---------------|
| 1 | 7 | 4 | 3 | 1 | 11 | asteroid7 | cargo-bay4 |
| 2 | 6 | 5 | 4 | 1 | 11 | asteroid8 | cargo-bay5 |
| 3 | 7 | 6 | 3 | 3 | 13 | asteroid9 | cargo-bay6 |
| 4 | 5 | 7 | 5 | 2 | 12 | asteroid10 | cargo-bay4 |
| 5 | 6 | 8 | 4 | 4 | 14 | asteroid11 | cargo-bay5 |
| 6 | 7 | 9 | 3 | 6 | 16 | asteroid12 | cargo-bay6 |
## Page 3 — Hard (A=2,3,4)
Hero: splitter9 (right), Footer: cabin5
| # | A | B | N=10-A | M=B-N | C=A+B | Asteroid img | Cargo bay img |
|---|---|---|--------|-------|-------|--------------|---------------|
| 1 | 4 | 7 | 6 | 1 | 11 | asteroid13 | cargo-bay7 |
| 2 | 3 | 8 | 7 | 1 | 11 | asteroid14 | cargo-bay8 |
| 3 | 4 | 8 | 6 | 2 | 12 | asteroid15 | cargo-bay9 |
| 4 | 3 | 9 | 7 | 2 | 12 | asteroid16 | cargo-bay7 |
| 5 | 4 | 9 | 6 | 3 | 13 | asteroid1 | cargo-bay8 |
| 6 | 2 | 9 | 8 | 1 | 11 | asteroid2 | cargo-bay9 |
## Hero Orientation
- Page 1: splitter3, right side (`flex-row-reverse`)
- Page 2: splitter6, left side (default `flex-row`)
- Page 3: splitter9, right side (`flex-row-reverse`)
## Footer Images
- Page 1: cabin1.jpeg
- Page 2: cabin3.jpeg
- Page 3: cabin5.jpeg

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,560 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cargo Filling Editor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; }
#toolbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 1000;
background: #16213e; border-bottom: 1px solid #0f3460;
display: flex; align-items: center; gap: 12px; padding: 8px 16px; font-size: 14px;
}
#toolbar button {
padding: 6px 14px; border: 1px solid #0f3460; border-radius: 6px;
background: #1a1a2e; color: #e0e0e0; cursor: pointer; font-size: 13px;
}
#toolbar button:hover { background: #0f3460; }
#toolbar button.primary { background: #533483; border-color: #7b2d8e; }
#toolbar button.save { background: #1b8a5a; border-color: #239b6e; }
#page-indicator { color: #a0a0c0; font-weight: 600; min-width: 100px; text-align: center; }
.spacer { flex: 1; }
#statusbar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
background: #16213e; border-top: 1px solid #0f3460;
padding: 6px 16px; font-size: 12px; color: #a0a0c0;
}
#worksheet-container {
margin-top: 52px; margin-bottom: 32px;
display: flex; flex-direction: column; align-items: center; gap: 20px; padding: 20px;
}
.page-label {
position: absolute; top: -24px; left: 50%; transform: translateX(-50%);
background: #533483; color: white; font-size: 11px; font-weight: 700;
padding: 2px 12px; border-radius: 4px;
}
.editor-selected { outline: 3px solid #7c3aed !important; outline-offset: 4px; }
.editor-changed { position: relative; }
.editor-changed::after {
content: ''; position: absolute; top: -2px; right: -2px;
width: 8px; height: 8px; background: #f97316; border-radius: 50%; z-index: 10;
}
#toast {
position: fixed; bottom: 50px; left: 50%; transform: translateX(-50%);
background: #533483; color: white; padding: 8px 20px; border-radius: 8px;
font-size: 13px; font-weight: 600; opacity: 0; transition: opacity 0.3s;
z-index: 2000; pointer-events: none;
}
#toast.show { opacity: 1; }
#coord-tooltip {
position: fixed; display: none; background: rgba(0,0,0,0.8); color: #e0e0e0;
padding: 3px 8px; border-radius: 4px; font-size: 11px; z-index: 2000;
pointer-events: none; white-space: nowrap;
}
.card-inner { overflow: visible !important; }
</style>
</head>
<body>
<div id="toolbar">
<button id="btn-prev">&larr; Prev</button>
<span id="page-indicator">Page 1 / ?</span>
<button id="btn-next">Next &rarr;</button>
<div class="spacer"></div>
<span id="selection-info" style="color: #c4b5fd; font-size: 12px;">Loading...</span>
<div class="spacer"></div>
<button id="btn-reset">Reset Page</button>
<button id="btn-copy" class="primary">Copy JSON</button>
<button id="btn-copy-changes" class="primary">Copy Changes</button>
<button id="btn-save" class="save">Save</button>
</div>
<div id="worksheet-container"></div>
<div id="statusbar">
<span id="status-text">Keys: +/- scale/resize &bull; arrows move &bull; 0 reset &bull; Esc deselect</span>
</div>
<div id="toast"></div>
<div id="coord-tooltip"></div>
<script src="../../src/editor/editor-core.js"></script>
<script>
(function() {
'use strict';
// Selection modes
var MODE_ASTEROID = 'asteroid';
var MODE_SHIP = 'ship';
var MODE_INNER = 'inner';
var MODE_BADGE = 'badge';
// Default values
var DEFAULTS = {
asteroidScale: 1.0,
ship: { right: -12.5, top: -5, width: 90, height: 42 },
innerAst: { left: 58, top: 73 },
badge: { left: 40, top: 39 }
};
var allCards = []; // [{el, spaceAsteroid, spaceAsteroidImg, cargoShip, badge, innerAst, remnantContainer, pageNum, cardIndex}]
var originals = new Map(); // "pageNum-cardIndex" → {asteroidScale, ship:{...}, innerAst:{...}}
var selected = null; // {card, mode}
var mmToPxRatio = 1;
function cardKey(card) {
return card.pageNum + '-' + card.cardIndex;
}
// --- Read current state from DOM ---
function getAsteroidScale(card) {
var t = card.spaceAsteroidImg.style.transform || '';
var m = t.match(/scale\(([\d.]+)\)/);
return m ? parseFloat(m[1]) : 1.0;
}
function setAsteroidScale(card, scale) {
// space-asteroid img
if (scale === 1.0) {
card.spaceAsteroidImg.style.transform = '';
} else {
card.spaceAsteroidImg.style.transform = 'scale(' + scale + ')';
}
// remnant shape container
if (card.remnantContainer) {
if (scale === 1.0) {
card.remnantContainer.style.transform = '';
} else {
card.remnantContainer.style.transform = 'scale(' + scale + ')';
}
}
// inner-ast: must compose with translate(-50%,-50%)
if (card.innerAst) {
if (scale === 1.0) {
card.innerAst.style.transform = 'translate(-50%, -50%)';
} else {
card.innerAst.style.transform = 'translate(-50%, -50%) scale(' + scale + ')';
}
}
}
function getShipValues(card) {
var cs = card.cargoShip.style;
return {
right: parseFloat(cs.right) || DEFAULTS.ship.right,
top: parseFloat(cs.top) || DEFAULTS.ship.top,
width: parseFloat(cs.width) || DEFAULTS.ship.width,
height: parseFloat(cs.height) || DEFAULTS.ship.height
};
}
function setShipValues(card, vals) {
card.cargoShip.style.right = vals.right + 'mm';
card.cargoShip.style.top = vals.top + 'mm';
card.cargoShip.style.width = vals.width + 'mm';
card.cargoShip.style.height = vals.height + 'mm';
}
function getInnerAstValues(card) {
var s = card.innerAst.style;
var left = parseFloat(s.left);
var top = parseFloat(s.top);
// If no inline style, read from computed or defaults
if (isNaN(left)) left = DEFAULTS.innerAst.left;
if (isNaN(top)) top = DEFAULTS.innerAst.top;
return { left: left, top: top };
}
function setInnerAstValues(card, vals) {
card.innerAst.style.left = vals.left + '%';
card.innerAst.style.top = vals.top + '%';
}
function getBadgeValues(card) {
if (!card.badge) return { left: DEFAULTS.badge.left, top: DEFAULTS.badge.top };
var s = card.badge.style;
var left = parseFloat(s.left);
var top = parseFloat(s.top);
if (isNaN(left)) left = DEFAULTS.badge.left;
if (isNaN(top)) top = DEFAULTS.badge.top;
return { left: left, top: top };
}
function setBadgeValues(card, vals) {
if (!card.badge) return;
card.badge.style.left = vals.left + '%';
card.badge.style.top = vals.top + '%';
}
// --- Get full card state ---
function getCardState(card) {
return {
asteroidScale: getAsteroidScale(card),
ship: getShipValues(card),
innerAst: card.innerAst ? getInnerAstValues(card) : { left: DEFAULTS.innerAst.left, top: DEFAULTS.innerAst.top },
badge: card.badge ? getBadgeValues(card) : { left: DEFAULTS.badge.left, top: DEFAULTS.badge.top }
};
}
function statesEqual(a, b) {
if (Math.abs(a.asteroidScale - b.asteroidScale) > 0.001) return false;
if (Math.abs(a.ship.right - b.ship.right) > 0.01) return false;
if (Math.abs(a.ship.top - b.ship.top) > 0.01) return false;
if (Math.abs(a.ship.width - b.ship.width) > 0.01) return false;
if (Math.abs(a.ship.height - b.ship.height) > 0.01) return false;
if (Math.abs(a.innerAst.left - b.innerAst.left) > 0.01) return false;
if (Math.abs(a.innerAst.top - b.innerAst.top) > 0.01) return false;
if (Math.abs(a.badge.left - b.badge.left) > 0.01) return false;
if (Math.abs(a.badge.top - b.badge.top) > 0.01) return false;
return true;
}
// --- Card discovery ---
function findCards(pages, mmToPx) {
mmToPxRatio = mmToPx;
allCards.length = 0;
pages.forEach(function(page, pi) {
var taskCards = page.querySelectorAll('.task-card');
taskCards.forEach(function(el, ci) {
var spaceAsteroid = el.querySelector('.space-asteroid');
var spaceAsteroidImg = spaceAsteroid ? spaceAsteroid.querySelector('img') : null;
var cargoShip = el.querySelector('.cargo-ship');
var badge = el.querySelector('.badge-10');
var innerAst = el.querySelector('.inner-ast');
var remnantContainer = el.querySelector('.remnant-shape .shape-container');
var card = {
el: el,
spaceAsteroid: spaceAsteroid,
spaceAsteroidImg: spaceAsteroidImg,
cargoShip: cargoShip,
badge: badge,
innerAst: innerAst,
remnantContainer: remnantContainer,
pageNum: pi + 1,
cardIndex: ci
};
allCards.push(card);
// Store initial state as original (will be overridden by applyData)
originals.set(cardKey(card), {
asteroidScale: DEFAULTS.asteroidScale,
ship: Object.assign({}, DEFAULTS.ship),
innerAst: Object.assign({}, DEFAULTS.innerAst),
badge: Object.assign({}, DEFAULTS.badge)
});
// Click handlers
if (spaceAsteroid) {
spaceAsteroid.style.cursor = 'pointer';
spaceAsteroid.addEventListener('click', function(e) {
e.stopPropagation();
selectCard(card, MODE_ASTEROID);
});
}
if (cargoShip) {
cargoShip.style.cursor = 'pointer';
cargoShip.addEventListener('click', function(e) {
if (e.target.closest('.inner-ast')) return;
if (e.target.closest('.badge-10')) return;
e.stopPropagation();
selectCard(card, MODE_SHIP);
});
}
if (badge) {
badge.style.cursor = 'pointer';
badge.style.zIndex = '20';
badge.addEventListener('click', function(e) {
e.stopPropagation();
selectCard(card, MODE_BADGE);
});
}
if (innerAst) {
innerAst.style.cursor = 'pointer';
innerAst.addEventListener('click', function(e) {
e.stopPropagation();
selectCard(card, MODE_INNER);
});
}
});
});
document.getElementById('status-text').textContent =
allCards.length + ' cards. Keys: +/- scale/resize \u2022 arrows move \u2022 0 reset \u2022 Esc deselect';
document.getElementById('selection-info').textContent = 'Click an element to select';
}
// --- Selection ---
function selectCard(card, mode) {
deselectAll();
selected = { card: card, mode: mode };
var highlight;
if (mode === MODE_ASTEROID) highlight = card.spaceAsteroid;
else if (mode === MODE_SHIP) highlight = card.cargoShip;
else if (mode === MODE_INNER) highlight = card.innerAst;
else if (mode === MODE_BADGE) highlight = card.badge;
if (highlight) highlight.classList.add('editor-selected');
updateInfo();
}
function deselectAll() {
if (selected) {
var card = selected.card;
if (card.spaceAsteroid) card.spaceAsteroid.classList.remove('editor-selected');
if (card.cargoShip) card.cargoShip.classList.remove('editor-selected');
if (card.innerAst) card.innerAst.classList.remove('editor-selected');
selected = null;
}
updateInfo();
}
function updateInfo() {
var el = document.getElementById('selection-info');
if (!selected) { el.textContent = 'Click an element to select'; return; }
var c = selected.card;
var prefix = 'P' + c.pageNum + ' C' + (c.cardIndex + 1) + ' | ';
if (selected.mode === MODE_ASTEROID) {
var s = getAsteroidScale(c);
el.textContent = prefix + 'Asteroid scale: ' + s.toFixed(2);
} else if (selected.mode === MODE_SHIP) {
var v = getShipValues(c);
el.textContent = prefix + 'Ship R:' + v.right.toFixed(1) + 'mm T:' + v.top.toFixed(1) + 'mm ' + v.width.toFixed(0) + '\u00D7' + v.height.toFixed(0) + 'mm';
} else if (selected.mode === MODE_INNER) {
var iv = getInnerAstValues(c);
el.textContent = prefix + 'Inner L:' + iv.left.toFixed(1) + '% T:' + iv.top.toFixed(1) + '%';
} else if (selected.mode === MODE_BADGE) {
var bv = getBadgeValues(c);
el.textContent = prefix + 'Badge L:' + bv.left.toFixed(1) + '% T:' + bv.top.toFixed(1) + '%';
}
}
// --- Change tracking ---
function markChanged(card) {
var orig = originals.get(cardKey(card));
var cur = getCardState(card);
var changed = !statesEqual(cur, orig);
card.el.classList.toggle('editor-changed', changed);
}
// --- Apply data.json to DOM ---
function applyData(data) {
if (!data || !data.pages) return;
var count = 0;
data.pages.forEach(function(p) {
(p.cards || []).forEach(function(cd) {
var card = allCards.find(function(c) {
return c.pageNum === p.page && c.cardIndex === cd.index;
});
if (!card) return;
var scale = cd.asteroidScale != null ? cd.asteroidScale : DEFAULTS.asteroidScale;
if (cd.asteroidScale != null) {
setAsteroidScale(card, scale);
}
if (cd.ship) {
var sv = {
right: cd.ship.right != null ? cd.ship.right : DEFAULTS.ship.right,
top: cd.ship.top != null ? cd.ship.top : DEFAULTS.ship.top,
width: cd.ship.width != null ? cd.ship.width : DEFAULTS.ship.width,
height: cd.ship.height != null ? cd.ship.height : DEFAULTS.ship.height
};
setShipValues(card, sv);
}
if (cd.innerAst && card.innerAst) {
var iv = {
left: cd.innerAst.left != null ? cd.innerAst.left : DEFAULTS.innerAst.left,
top: cd.innerAst.top != null ? cd.innerAst.top : DEFAULTS.innerAst.top
};
setInnerAstValues(card, iv);
}
// Update originals to saved state
originals.set(cardKey(card), getCardState(card));
card.el.classList.remove('editor-changed');
count++;
});
});
if (count > 0) core.showToast('Loaded ' + count + ' card edits');
}
// --- Serialization ---
function buildConfig(changesOnly) {
var pagesMap = {};
allCards.forEach(function(card) {
var cur = getCardState(card);
var orig = originals.get(cardKey(card));
var cardData = { index: card.cardIndex };
var hasChange = false;
// Asteroid scale
if (Math.abs(cur.asteroidScale - DEFAULTS.asteroidScale) > 0.001) {
if (!changesOnly || Math.abs(cur.asteroidScale - orig.asteroidScale) > 0.001) {
cardData.asteroidScale = +cur.asteroidScale.toFixed(3);
hasChange = true;
}
} else if (!changesOnly) {
// At default, skip
}
// Ship
var shipChanged = false;
var shipData = {};
if (Math.abs(cur.ship.right - DEFAULTS.ship.right) > 0.01) { shipData.right = +cur.ship.right.toFixed(1); shipChanged = true; }
if (Math.abs(cur.ship.top - DEFAULTS.ship.top) > 0.01) { shipData.top = +cur.ship.top.toFixed(1); shipChanged = true; }
if (Math.abs(cur.ship.width - DEFAULTS.ship.width) > 0.01) { shipData.width = +cur.ship.width.toFixed(1); shipChanged = true; }
if (Math.abs(cur.ship.height - DEFAULTS.ship.height) > 0.01) { shipData.height = +cur.ship.height.toFixed(1); shipChanged = true; }
if (shipChanged) {
if (!changesOnly || !statesEqual(cur, orig)) {
cardData.ship = shipData;
hasChange = true;
}
}
// Inner asteroid
if (card.innerAst) {
var innerChanged = false;
var innerData = {};
if (Math.abs(cur.innerAst.left - DEFAULTS.innerAst.left) > 0.01) { innerData.left = +cur.innerAst.left.toFixed(1); innerChanged = true; }
if (Math.abs(cur.innerAst.top - DEFAULTS.innerAst.top) > 0.01) { innerData.top = +cur.innerAst.top.toFixed(1); innerChanged = true; }
if (innerChanged) {
if (!changesOnly || Math.abs(cur.innerAst.left - orig.innerAst.left) > 0.01 || Math.abs(cur.innerAst.top - orig.innerAst.top) > 0.01) {
cardData.innerAst = innerData;
hasChange = true;
}
}
}
if (changesOnly && !hasChange) return;
if (!changesOnly && !hasChange) return;
if (!pagesMap[card.pageNum]) pagesMap[card.pageNum] = { page: card.pageNum, cards: [] };
pagesMap[card.pageNum].cards.push(cardData);
});
return { pages: Object.values(pagesMap) };
}
// --- Reset ---
function resetCurrentPage(pageNum) {
allCards.forEach(function(card) {
if (card.pageNum !== pageNum) return;
var orig = originals.get(cardKey(card));
setAsteroidScale(card, orig.asteroidScale);
setShipValues(card, orig.ship);
if (card.innerAst) setInnerAstValues(card, orig.innerAst);
card.el.classList.remove('editor-changed');
});
if (selected && selected.card.pageNum === pageNum) updateInfo();
}
// --- Keyboard ---
function setupKeyboard() {
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { deselectAll(); return; }
if (!selected) return;
var card = selected.card;
var mode = selected.mode;
if (mode === MODE_ASTEROID) {
var scale = getAsteroidScale(card);
var step = e.shiftKey ? 0.01 : 0.05;
switch (e.key) {
case '+': case '=':
setAsteroidScale(card, Math.min(2, +(scale + step).toFixed(3)));
markChanged(card); updateInfo(); e.preventDefault(); break;
case '-': case '_':
setAsteroidScale(card, Math.max(0.3, +(scale - step).toFixed(3)));
markChanged(card); updateInfo(); e.preventDefault(); break;
case '0':
setAsteroidScale(card, DEFAULTS.asteroidScale);
markChanged(card); updateInfo(); e.preventDefault(); break;
}
} else if (mode === MODE_SHIP) {
var sv = getShipValues(card);
var moveStep = e.shiftKey ? 0.5 : 2;
var sizeStep = e.shiftKey ? 1 : 2;
switch (e.key) {
case 'ArrowLeft':
sv.right = +(sv.right + moveStep).toFixed(1); // left = increase right
setShipValues(card, sv); markChanged(card); updateInfo(); e.preventDefault(); break;
case 'ArrowRight':
sv.right = +(sv.right - moveStep).toFixed(1); // right = decrease right
setShipValues(card, sv); markChanged(card); updateInfo(); e.preventDefault(); break;
case 'ArrowUp':
sv.top = +(sv.top - moveStep).toFixed(1);
setShipValues(card, sv); markChanged(card); updateInfo(); e.preventDefault(); break;
case 'ArrowDown':
sv.top = +(sv.top + moveStep).toFixed(1);
setShipValues(card, sv); markChanged(card); updateInfo(); e.preventDefault(); break;
case '+': case '=':
sv.width = +(sv.width + sizeStep).toFixed(1);
sv.height = +(sv.height + sizeStep * (DEFAULTS.ship.height / DEFAULTS.ship.width)).toFixed(1);
setShipValues(card, sv); markChanged(card); updateInfo(); e.preventDefault(); break;
case '-': case '_':
sv.width = Math.max(20, +(sv.width - sizeStep).toFixed(1));
sv.height = Math.max(10, +(sv.height - sizeStep * (DEFAULTS.ship.height / DEFAULTS.ship.width)).toFixed(1));
setShipValues(card, sv); markChanged(card); updateInfo(); e.preventDefault(); break;
case '0':
setShipValues(card, Object.assign({}, DEFAULTS.ship));
markChanged(card); updateInfo(); e.preventDefault(); break;
}
} else if (mode === MODE_INNER) {
var iv = getInnerAstValues(card);
var pctStep = e.shiftKey ? 0.5 : 1;
switch (e.key) {
case 'ArrowLeft':
iv.left = +(iv.left - pctStep).toFixed(1);
setInnerAstValues(card, iv); markChanged(card); updateInfo(); e.preventDefault(); break;
case 'ArrowRight':
iv.left = +(iv.left + pctStep).toFixed(1);
setInnerAstValues(card, iv); markChanged(card); updateInfo(); e.preventDefault(); break;
case 'ArrowUp':
iv.top = +(iv.top - pctStep).toFixed(1);
setInnerAstValues(card, iv); markChanged(card); updateInfo(); e.preventDefault(); break;
case 'ArrowDown':
iv.top = +(iv.top + pctStep).toFixed(1);
setInnerAstValues(card, iv); markChanged(card); updateInfo(); e.preventDefault(); break;
case '0':
setInnerAstValues(card, Object.assign({}, DEFAULTS.innerAst));
// Also reset transform to just translate (remove scale if any)
card.innerAst.style.transform = 'translate(-50%, -50%)';
markChanged(card); updateInfo(); e.preventDefault(); break;
}
}
});
document.addEventListener('click', function(e) {
if (selected && !e.target.closest('.space-asteroid') && !e.target.closest('.cargo-ship') && !e.target.closest('.inner-ast')) {
deselectAll();
}
});
}
// --- Init via EditorCore ---
var core = EditorCore.init({
taskType: 'cargo-filling',
serialize: buildConfig,
onReset: resetCurrentPage,
onReady: function(pages, mmToPx) {
findCards(pages, mmToPx);
setupKeyboard();
},
onDataLoaded: function(data) {
if (data) applyData(data);
}
});
})();
</script>
</body>
</html>

View File

@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fill the Cargo Bay &mdash; Space Math Adventures</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg-start: #eef0ff; --bg-mid: #fef3e2; --bg-end: #e8faf3;
--text-deep: #2d1b69; --text-body: #3d2d6b;
--accent-violet: #7c3aed; --accent-orange: #f97316; --accent-teal: #0d9488; --accent-pink: #ec4899;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Nunito', sans-serif;
background: linear-gradient(170deg, var(--bg-start) 0%, var(--bg-mid) 50%, var(--bg-end) 100%);
color: var(--text-body); min-height: 100vh; overflow-x: hidden;
display: flex; flex-direction: column;
}
.page-content { flex: 1; }
.container { position: relative; z-index: 1; max-width: 920px; margin: 0 auto; padding: 0 24px; }
.back-link {
display: inline-flex; align-items: center; gap: 6px;
color: var(--accent-violet); text-decoration: none; font-size: 0.9rem; font-weight: 700;
padding-top: 40px; transition: color 0.15s;
}
.back-link:hover { color: var(--accent-pink); }
.page-header { display: flex; align-items: center; gap: 24px; padding: 20px 0 12px; }
.page-header img {
width: 110px; filter: drop-shadow(0 4px 12px rgba(124,58,237,0.2));
animation: headerBob 3s ease-in-out infinite;
}
@keyframes headerBob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
.page-header h1 {
font-family: 'Fredoka', sans-serif; font-size: 2rem; font-weight: 700;
background: linear-gradient(135deg, var(--accent-violet), var(--accent-teal));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.header-desc {
color: var(--text-body); font-size: 0.95rem; line-height: 1.6; font-weight: 600;
padding-bottom: 28px; border-bottom: 2px solid rgba(124,58,237,0.12); margin-bottom: 28px;
}
.docs { display: flex; flex-direction: column; gap: 20px; padding-bottom: 40px; }
.doc-card {
display: flex;
background: linear-gradient(135deg, rgba(255,255,255,0.85), rgba(255,248,240,0.8));
border: 2px solid transparent; border-radius: 20px;
overflow: hidden; position: relative;
transition: transform 0.2s, box-shadow 0.2s;
backdrop-filter: blur(8px);
}
.doc-card::before {
content: ''; position: absolute; inset: -2px; border-radius: 22px;
background: linear-gradient(135deg, var(--accent-violet), var(--accent-teal));
z-index: -1; opacity: 0.25; transition: opacity 0.2s;
}
.doc-card:hover { transform: translateY(-3px); box-shadow: 0 12px 36px rgba(124,58,237,0.14); }
.doc-card:hover::before { opacity: 0.5; }
.doc-preview {
width: 170px; flex-shrink: 0; display: flex; align-items: flex-start; justify-content: center;
padding: 12px; background: linear-gradient(135deg, rgba(124,58,237,0.06), rgba(13,148,136,0.04));
}
.doc-preview img { width: 100%; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); }
.doc-info { padding: 20px 24px; flex: 1; }
.doc-info h3 {
font-family: 'Fredoka', sans-serif; font-size: 1.2rem; font-weight: 600;
color: var(--text-deep); margin-bottom: 6px;
}
.doc-details { font-size: 0.83rem; color: var(--text-body); line-height: 1.6; margin-bottom: 14px; }
.tag {
display: inline-block; padding: 2px 10px; border-radius: 12px;
font-size: 0.75rem; font-weight: 700; margin-right: 4px; margin-bottom: 4px;
}
.tag-pages { background: rgba(124,58,237,0.1); color: var(--accent-violet); border: 1px solid rgba(124,58,237,0.2); }
.tag-diff { background: rgba(13,148,136,0.1); color: var(--accent-teal); border: 1px solid rgba(13,148,136,0.2); }
.doc-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.doc-actions a {
font-size: 0.72rem; font-weight: 700; text-decoration: none;
padding: 4px 12px; border-radius: 8px; transition: all 0.15s;
display: inline-flex; align-items: center; gap: 4px;
}
.btn-preview {
color: var(--accent-violet); border: 2px solid rgba(124,58,237,0.25);
background: linear-gradient(135deg, rgba(124,58,237,0.06), rgba(124,58,237,0.02));
}
.btn-preview:hover { background: rgba(124,58,237,0.12); border-color: var(--accent-violet); }
.btn-pdf {
color: var(--accent-teal); border: 1.5px solid rgba(13,148,136,0.3);
background: linear-gradient(135deg, rgba(13,148,136,0.06), rgba(13,148,136,0.02));
}
.btn-pdf:hover { background: rgba(13,148,136,0.15); border-color: var(--accent-teal); }
.btn-pdf svg { width: 12px; height: 12px; }
.btn-edit {
color: var(--accent-orange); border: 1.5px solid rgba(249,115,22,0.3);
background: linear-gradient(135deg, rgba(249,115,22,0.06), rgba(249,115,22,0.02));
display: none;
}
.btn-edit:hover { background: rgba(249,115,22,0.15); border-color: var(--accent-orange); }
body.editor-mode .btn-edit { display: inline-flex; }
/* Footer */
.site-footer { position: relative; }
.planet-footer { height: 140px; overflow: hidden; }
.planet-footer img {
width: 100%; height: 100%; object-fit: cover;
mask-image: linear-gradient(to bottom, transparent 0%, black 50%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 50%);
}
.footer-bar {
position: absolute; bottom: 8px; left: 0; right: 0;
text-align: center; z-index: 2;
}
.editor-toggle {
cursor: pointer; user-select: none;
color: rgba(255,255,255,0.4); font-size: 0.72rem; font-weight: 600;
display: inline-flex; align-items: center; gap: 6px;
transition: color 0.2s;
}
.editor-toggle:hover { color: rgba(255,255,255,0.7); }
.editor-toggle input { accent-color: var(--accent-violet); }
@media (max-width: 640px) {
.doc-card { flex-direction: column; }
.doc-preview { width: 100%; max-height: 160px; }
.page-header img { width: 70px; }
.page-header h1 { font-size: 1.5rem; }
}
</style>
</head>
<body>
<div class="page-content">
<div class="container">
<a class="back-link" href="/tasks/index.html">&larr; All Categories</a>
<div class="page-header">
<img src="/assets/icons/pack4-cargobay/cargo-bay1.png" alt="">
<h1>Fill the Cargo Bay</h1>
</div>
<p class="header-desc">Split an asteroid to fill the remaining space in a cargo bay! Each card shows a ship with cargo already inside, and an asteroid that's too big to fit whole. The child splits the asteroid so one piece fills the bay to exactly 10. Practices addition crossing the tens boundary.</p>
<div class="docs">
<div class="doc-card">
<div class="doc-preview"><img src="/tasks/cargo-filling/temp/cargo-filling-1-page-1.png" alt="Preview"></div>
<div class="doc-info">
<h3>Cargo Filling 1</h3>
<div class="doc-details">
<span class="tag tag-pages">3 pages</span>
<span class="tag tag-diff">Easy &rarr; Hard</span><br>
6 cards per page &bull; Split asteroid to fill cargo bay to 10 &bull; Addition through 10
</div>
<div class="doc-actions">
<a class="btn-preview" href="/tasks/cargo-filling/docs/cargo-filling-1.output.html">Preview</a>
<a class="btn-pdf" href="/output/pdf/cargo-filling-1.pdf" download><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>PDF</a>
<a class="btn-edit" href="/tasks/cargo-filling/editor.html?file=cargo-filling-1">Editor</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="site-footer">
<div class="planet-footer">
<img src="/assets/footers/planet3.jpeg" alt="">
</div>
<div class="footer-bar">
<label class="editor-toggle">
<input type="checkbox" onchange="document.body.classList.toggle('editor-mode', this.checked)">
Unlock editor (local dev only)
</label>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,192 @@
#!/usr/bin/env node
/**
* Generate output HTML from template + data for cargo-filling documents.
*
* Usage: node generate.mjs <docId>
* Example: node generate.mjs cargo-filling-1
*
* Reads: docs/<docId>.template.html
* Reads: docs/<docId>.data.json (optional)
* Writes: docs/<docId>.output.html
*
* data.json format:
* {
* pages: [{
* page: 1,
* cards: [{
* index: 0,
* asteroidScale: 1.15,
* ship: { right: -10, top: -3, width: 85, height: 40 },
* innerAst: { left: 60, top: 70 }
* }]
* }]
* }
*
* Transforms:
* - asteroidScale: scale on .space-asteroid img, .remnant-shape .shape-container, .inner-ast
* - ship: inline right/top/width/height on .cargo-ship
* - innerAst: inline left/top on .inner-ast (composed with translate and optional scale)
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { postGenerate } from '../../../src/scripts/post-generate.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const docsDir = join(__dirname, '..', 'docs');
const docId = process.argv[2];
if (!docId) {
console.error('Usage: node generate.mjs <docId>');
process.exit(1);
}
const templatePath = join(docsDir, `${docId}.template.html`);
const dataPath = join(docsDir, `${docId}.data.json`);
const outputPath = join(docsDir, `${docId}.output.html`);
if (!existsSync(templatePath)) {
console.error(`Template not found: ${templatePath}`);
process.exit(1);
}
let html = readFileSync(templatePath, 'utf-8');
if (existsSync(dataPath)) {
const data = JSON.parse(readFileSync(dataPath, 'utf-8'));
html = applyData(html, data);
console.log(`Applied data from ${data.pages?.length || 0} pages`);
}
writeFileSync(outputPath, html);
console.log(`Generated: ${outputPath}`);
await postGenerate(outputPath);
function applyData(html, data) {
if (!data.pages) return html;
// Split HTML into pages by w-[210mm] h-[297mm] divs
const pageRegex = /<div class="w-\[210mm\] h-\[297mm\]/g;
const starts = [];
let match;
while ((match = pageRegex.exec(html)) !== null) {
starts.push(match.index);
}
if (starts.length === 0) return html;
// Process pages in reverse to preserve indices
for (let i = data.pages.length - 1; i >= 0; i--) {
const pageData = data.pages[i];
const pageNum = pageData.page || (i + 1);
const pageIdx = pageNum - 1;
if (pageIdx >= starts.length) continue;
const pageStart = starts[pageIdx];
const pageEnd = pageIdx + 1 < starts.length ? starts[pageIdx + 1] : html.length;
let pageHtml = html.slice(pageStart, pageEnd);
if (pageData.cards) {
pageHtml = applyCards(pageHtml, pageData.cards);
}
html = html.slice(0, pageStart) + pageHtml + html.slice(pageEnd);
}
return html;
}
function applyCards(pageHtml, cards) {
// Find card boundaries by <!-- Card comments (two formats: "Card 1:" and "Card:")
const cardRegex = /<!-- Card[\s:]/g;
const cardStarts = [];
let match;
while ((match = cardRegex.exec(pageHtml)) !== null) {
cardStarts.push(match.index);
}
if (cardStarts.length === 0) return pageHtml;
// Process cards in reverse to preserve indices
for (let i = cards.length - 1; i >= 0; i--) {
const cardData = cards[i];
const cardIdx = cardData.index;
if (cardIdx >= cardStarts.length) continue;
const cardStart = cardStarts[cardIdx];
const cardEnd = cardIdx + 1 < cardStarts.length
? cardStarts[cardIdx + 1]
: pageHtml.length;
let cardHtml = pageHtml.slice(cardStart, cardEnd);
const scale = cardData.asteroidScale;
const ship = cardData.ship;
const innerAst = cardData.innerAst;
// Apply asteroidScale to .space-asteroid img
if (scale != null && scale !== 1) {
// Match <div class="space-asteroid"> then find the <img inside
cardHtml = cardHtml.replace(
/(<div class="space-asteroid">[\s]*<img [^>]*?)(>)/,
function(full, before, close) {
// Remove any existing style, then add new one
const cleaned = before.replace(/ style="[^"]*"/, '');
return cleaned + ' style="transform: scale(' + scale + ')"' + close;
}
);
}
// Apply asteroidScale to .remnant-shape .shape-container
if (scale != null && scale !== 1) {
cardHtml = cardHtml.replace(
/<div class="shape-container">/,
'<div class="shape-container" style="transform: scale(' + scale + ')">'
);
}
// Apply ship position/size to .cargo-ship
if (ship) {
const shipRight = ship.right != null ? ship.right : -12.5;
const shipTop = ship.top != null ? ship.top : -5;
const shipWidth = ship.width != null ? ship.width : 90;
const shipHeight = ship.height != null ? ship.height : 42;
const shipStyle = 'right: ' + shipRight + 'mm; top: ' + shipTop + 'mm; width: ' + shipWidth + 'mm; height: ' + shipHeight + 'mm';
cardHtml = cardHtml.replace(
/(<div class="cargo-ship")([\s>])/,
function(full, tag, after) {
return tag + ' style="' + shipStyle + '"' + after;
}
);
}
// Apply innerAst position (and compose with asteroidScale if present)
if (innerAst || (scale != null && scale !== 1)) {
const innerLeft = innerAst && innerAst.left != null ? innerAst.left : 58;
const innerTop = innerAst && innerAst.top != null ? innerAst.top : 73;
let transformPart = 'translate(-50%, -50%)';
if (scale != null && scale !== 1) {
transformPart = 'translate(-50%, -50%) scale(' + scale + ')';
}
let innerStyle = 'left: ' + innerLeft + '%; top: ' + innerTop + '%; transform: ' + transformPart;
// Only apply if we actually have something to change
const needsInnerChange = (innerAst && (innerAst.left != null || innerAst.top != null)) || (scale != null && scale !== 1);
if (needsInnerChange) {
cardHtml = cardHtml.replace(
/(<div class="inner-ast")([\s>])/,
function(full, tag, after) {
return tag + ' style="' + innerStyle + '"' + after;
}
);
}
}
pageHtml = pageHtml.slice(0, cardStart) + cardHtml + pageHtml.slice(cardEnd);
}
return pageHtml;
}

View File

@ -217,6 +217,20 @@
</div>
</div>
</a>
<a class="cat-card" href="/tasks/cargo-filling/">
<div class="cat-image">
<img src="/assets/icons/pack4-cargobay/cargo-bay1.png" alt="Fill the Cargo Bay">
</div>
<div class="cat-info">
<h2>Fill the Cargo Bay</h2>
<p class="cat-desc">Split an asteroid to fill a ship&rsquo;s cargo bay! The bay holds 10, but there&rsquo;s already cargo inside. Break the asteroid so one piece fills the gap. Practices addition crossing 10.</p>
<div class="cat-meta">
<span class="tag-violet">1 worksheet</span>
<span class="tag-orange">Addition Through 10</span>
</div>
</div>
</a>
</div>
</div>
</div>