feat: feel cargo doc
|
|
@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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">← Prev</button>
|
||||
<span id="page-indicator">Page 1 / ?</span>
|
||||
<button id="btn-next">Next →</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 • arrows move • 0 reset • 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>
|
||||
|
|
@ -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 — 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">← 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 → Hard</span><br>
|
||||
6 cards per page • Split asteroid to fill cargo bay to 10 • 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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’s cargo bay! The bay holds 10, but there’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>
|
||||
|
|
|
|||