SOURCE / PINNED RELEASE
Made of little things.
Squad Table
- Release
- d36201f8ed4f…
- Author-recorded commit
- 7b9bb681d6ba…
- License
- LICENSE
- Author’s source reference
- nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-c043b0c0f1d
Archive hash verified: 8c0604b31f00cc5a…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Original counter artwork as SVG markup. Units use NATO-style role boxes on
// nation-coloured chits; markers use tone colours so states read at a glance.
import type { NationId, Template } from './catalog.js';
import { templateOf, type Counter } from './model.js';
export const COUNTER = 34;
export const NATION_STYLE: Record<NationId, { face: string; edge: string; ink: string }> = {
ge: { face: '#97a3a6', edge: '#55605f', ink: '#101517' },
us: { face: '#a19a5a', edge: '#5c5829', ink: '#14140a' },
ru: { face: '#c4a66f', edge: '#77613a', ink: '#1e150a' },
mk: { face: '#e9e1c8', edge: '#8c8268', ink: '#1d1a12' },
};
const TONE_STYLE: Record<NonNullable<Template['tone']>, { face: string; edge: string; ink: string }> = {
fire: { face: '#b4462f', edge: '#6e2516', ink: '#fff3df' },
move: { face: '#dcb84a', edge: '#86691c', ink: '#211a06' },
state: { face: '#e5dcc3', edge: '#8a7f62', ink: '#2a2214' },
terrain: { face: '#5c5b52', edge: '#2f2e29', ink: '#f2ead3' },
info: { face: '#f3ecd6', edge: '#8c8268', ink: '#1d1a12' },
};
export function esc(text: string): string {
return text.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
}
export function styleOf(t: Template) {
if (t.nat === 'mk') return TONE_STYLE[t.tone ?? 'info'];
return NATION_STYLE[t.nat];
}
function glyph(t: Template, ink: string): string {
const box = `<rect x="-8" y="-13" width="16" height="9.5" fill="none" stroke="${ink}" stroke-width="1.1"/>`;
switch (t.type) {
case 'squad':
return `${box}<path d="M-8 -13 L8 -3.5 M8 -13 L-8 -3.5" stroke="${ink}" stroke-width="1"/>`;
case 'half':
return `${box}<path d="M-8 -3.5 L8 -13" stroke="${ink}" stroke-width="1"/>`;
case 'crew':
return `${box}<path d="M-8 -8.25 H8" stroke="${ink}" stroke-width="1"/><circle cx="0" cy="-8.25" r="2" fill="${ink}"/>`;
case 'leader':
return `<path d="M0 -14.5 L2.1 -10 L7 -9.6 L3.3 -6.5 L4.4 -1.8 L0 -4.3 L-4.4 -1.8 L-3.3 -6.5 L-7 -9.6 L-2.1 -10 Z" fill="${ink}"/>`;
case 'weapon':
return `<path d="M-9 -9 H7 M7 -10.5 V-7.5 M-2 -9 L-5 -3.5 M-2 -9 L1 -3.5" stroke="${ink}" stroke-width="1.4" fill="none" stroke-linecap="round"/>`;
case 'gun':
return `<circle cx="-4" cy="-6" r="3" fill="none" stroke="${ink}" stroke-width="1.2"/><circle cx="4" cy="-6" r="3" fill="none" stroke="${ink}" stroke-width="1.2"/><path d="M-6 -11 L10 -14" stroke="${ink}" stroke-width="1.6" stroke-linecap="round"/>`;
case 'vehicle':
return `<rect x="-9" y="-9" width="18" height="6" rx="3" fill="none" stroke="${ink}" stroke-width="1.2"/><rect x="-4" y="-13" width="8" height="4" fill="${ink}"/><path d="M4 -11 H11" stroke="${ink}" stroke-width="1.4"/>`;
default:
return '';
}
}
export type CounterStatus = { cx?: boolean; moved?: boolean; fired?: 'prep' | 'final' | 'adv'; pin?: boolean };
/** One counter centred on the origin. */
export function counterSvg(counter: Counter, extra = '', status: CounterStatus = {}): string {
const t = templateOf(counter);
const s = styleOf(t);
const half = COUNTER / 2;
const label = esc(counter.label ?? t.label);
const isMarker = t.type === 'marker' || t.type === 'fort';
const flipped = counter.face === 1;
const parts: string[] = [
`<rect x="${-half + 1.6}" y="${-half + 1.6}" width="${COUNTER}" height="${COUNTER}" rx="3" fill="#000" opacity="0.35"/>`,
`<rect x="${-half}" y="${-half}" width="${COUNTER}" height="${COUNTER}" rx="3" fill="${s.face}" stroke="${s.edge}" stroke-width="1.4"/>`,
`<rect x="${-half + 2}" y="${-half + 2}" width="${COUNTER - 4}" height="${COUNTER - 4}" rx="2" fill="none" stroke="#fff" stroke-opacity="0.28" stroke-width="0.8"/>`,
];
if (isMarker) {
const back = flipped && t.back ? esc(t.back) : label;
const size = back.length > 7 ? 6.4 : back.length > 4 ? 7.6 : back.length > 2 ? 10 : 14;
parts.push(
`<text y="${size / 3}" text-anchor="middle" font-size="${size}" font-weight="800" fill="${s.ink}" class="ctr-text">${back}</text>`,
);
} else {
parts.push(glyph(t, s.ink));
if (t.tag) {
parts.push(`<text y="-0.2" text-anchor="middle" font-size="5" letter-spacing="0.3" fill="${s.ink}" opacity="0.8" class="ctr-text">${esc(t.tag.toUpperCase())}</text>`);
}
const size = label.length > 6 ? 7 : 9.5;
parts.push(
`<text y="11.5" text-anchor="middle" font-size="${size}" font-weight="800" fill="${s.ink}" class="ctr-text" opacity="${flipped ? 0.55 : 1}">${label}</text>`,
);
if (flipped) {
parts.push(
`<path d="M${-half} ${half - 9} L${half - 9} ${-half} H${half} V${-half + 6} L${-half + 6} ${half} H${-half} Z" fill="#7b1f14" opacity="0.82"/>`,
`<text transform="rotate(-45)" y="2" text-anchor="middle" font-size="5.6" font-weight="800" letter-spacing="0.6" fill="#ffe9d6" class="ctr-text">${esc(t.back ?? 'BROKEN')}</text>`,
);
}
if ((t.type === 'vehicle' || t.type === 'gun') && counter.rot >= 0) {
const angle = -90 + counter.rot * 60;
parts.push(
`<g transform="rotate(${angle + 90})"><path d="M0 ${-half - 5} L4 ${-half - 0.5} H-4 Z" fill="${s.edge}" stroke="#fff" stroke-width="0.6"/></g>`,
);
}
}
if (status.moved) {
parts.push(
`<circle cx="${-half + 5}" cy="${-half + 5}" r="3.6" fill="#2b2b22" stroke="#f0e6c8" stroke-width="0.7"/><path d="M${-half + 3.2} ${-half + 5} l1.3 1.4 l2.4 -2.7" stroke="#f0e6c8" stroke-width="0.9" fill="none"/>`,
);
}
if (status.cx ?? counter.cx) {
parts.push(
`<rect x="${-half + 3}" y="${half - 8.5}" width="13" height="7" rx="1.5" fill="#dcb84a" stroke="#6b5414" stroke-width="0.6"/><text x="${-half + 9.5}" y="${half - 3}" text-anchor="middle" font-size="5.4" font-weight="800" fill="#211a06" class="ctr-text">CX</text>`,
);
}
const fired = 'fired' in status ? status.fired : counter.fired;
if (fired) {
const tag = fired === 'prep' ? 'PF' : fired === 'final' ? 'FF' : 'AF';
parts.push(
`<rect x="${half - 14}" y="${-half - 3}" width="13" height="7.5" rx="1.5" fill="#b4462f" stroke="#5e1d10" stroke-width="0.6"/><text x="${half - 7.5}" y="${-half + 2.6}" text-anchor="middle" font-size="5.4" font-weight="800" fill="#fff3df" class="ctr-text">${tag}</text>`,
);
}
if (status.pin ?? counter.pin) {
parts.push(
`<rect x="${half - 16}" y="${half - 8.5}" width="15" height="7" rx="1.5" fill="#e07b2a" stroke="#6e3608" stroke-width="0.6"/><text x="${half - 8.5}" y="${half - 3}" text-anchor="middle" font-size="5.2" font-weight="800" fill="#1d1004" class="ctr-text">PIN</text>`,
);
}
return `<g class="ctr" data-id="${esc(counter.id)}" ${extra}>${parts.join('')}</g>`;
}
/** A standalone small SVG for panels and the tray. */
export function counterIcon(counter: Counter, size = 40): string {
return `<svg class="ctr-icon" viewBox="-21 -21 42 42" width="${size}" height="${size}" aria-hidden="true">${counterSvg(counter)}</svg>`;
}
