← back to CelebritySignatures
Custom mural sizing: user enters mural width/height (ft) and signature chips re-arrange live at true print scale (~1.9x0.85ft/sig) — bigger walls pull the next famous-first set in (verified: Politics 8x4ft=16 sigs, 24x12ft=54 sigs, live sqft price); order payload carries custom dims + count. Plus: Authors category scaffolding (fetch-authors.mjs literary-occupation harvest, server merge, UI chips, /k/w collage QR) (TK-10181)
46fd5bdb8118687a16acd5fdddf462e013fbf753 · 2026-08-03 12:05:19 -0700 · Steve Abrams
Files touched
M public/index.htmlM public/murals.htmlM scripts/build-collages.pyA scripts/fetch-authors.mjsM server.js
Diff
commit 46fd5bdb8118687a16acd5fdddf462e013fbf753
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 3 12:05:19 2026 -0700
Custom mural sizing: user enters mural width/height (ft) and signature chips re-arrange live at true print scale (~1.9x0.85ft/sig) — bigger walls pull the next famous-first set in (verified: Politics 8x4ft=16 sigs, 24x12ft=54 sigs, live sqft price); order payload carries custom dims + count. Plus: Authors category scaffolding (fetch-authors.mjs literary-occupation harvest, server merge, UI chips, /k/w collage QR) (TK-10181)
---
public/index.html | 6 +--
public/murals.html | 83 +++++++++++++++++++++++++++++----
scripts/build-collages.py | 4 +-
scripts/fetch-authors.mjs | 115 ++++++++++++++++++++++++++++++++++++++++++++++
server.js | 8 +++-
5 files changed, 200 insertions(+), 16 deletions(-)
diff --git a/public/index.html b/public/index.html
index 7cdb5b6..ce2d5ae 100644
--- a/public/index.html
+++ b/public/index.html
@@ -200,7 +200,7 @@ function render() {
let rows = DATA.filter(r => (prefs.cat==='All' || r.category===prefs.cat)
&& (prefs.use==='all' || r.usable_in_commercial_collage===prefs.use)
&& (!q || r.full_name.toLowerCase().includes(q)));
- const cats = ['Oldest Signatures','Declaration of Independence','Politics','Artists'];
+ const cats = ['Oldest Signatures','Declaration of Independence','Politics','Artists','Authors'];
const sorters = {
cat: (a,b) => cats.indexOf(a.category)-cats.indexOf(b.category) || a.rank-b.rank,
name: (a,b) => a.full_name.localeCompare(b.full_name),
@@ -237,7 +237,7 @@ function render() {
}
function buildChips() {
- const cats = ['All','Oldest Signatures','Declaration of Independence','Politics','Artists'];
+ const cats = ['All','Oldest Signatures','Declaration of Independence','Politics','Artists','Authors'];
const counts = {}; DATA.forEach(r => { counts[r.category]=(counts[r.category]||0)+1; });
counts['All'] = DATA.length;
$('#catChips').innerHTML = cats.map(c => `<span class="chip${c===prefs.cat?' on':''}" data-c="${c}">${c}<span class="chip-count">${counts[c]||0}</span></span>`).join('');
@@ -263,7 +263,7 @@ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,
let collagesBuilt = false;
function buildCollages() {
if (collagesBuilt) return; collagesBuilt = true;
- const order = ['Oldest Signatures','Declaration of Independence','Politics','Artists'];
+ const order = ['Oldest Signatures','Declaration of Independence','Politics','Artists','Authors'];
const usable = {}; DATA.forEach(r => { if(r.usable_in_commercial_collage==='yes') usable[r.category]=(usable[r.category]||0)+1; });
const cats = [...new Set([...order, ...Object.keys(usable)])].filter(c => order.includes(c) || usable[c]);
$('#collagesView').innerHTML = cats.map(c => {
diff --git a/public/murals.html b/public/murals.html
index b5cf69c..204859d 100644
--- a/public/murals.html
+++ b/public/murals.html
@@ -118,6 +118,10 @@
.modal .badge{display:inline-block;font-size:11px;padding:2px 8px;border-radius:999px;background:#eaf5ec;color:#1d7a36;border:1px solid #cfe8d5}
.modal .badge.medium{background:#fdf4e3;color:#9a6b00;border-color:#f0e2c0}
.modal .badge.high{background:#fdecea;color:#b3261e;border-color:#f3cdc8}
+ /* — custom-size live signature grid inside the mural — */
+ .mural .sig-grid{position:absolute;inset:0;display:grid;gap:2px;padding:3px;background:#fff}
+ .mural .sg-cell{display:flex;align-items:center;justify-content:center;background:#fff;overflow:hidden}
+ .mural .sg-cell img{max-width:94%;max-height:88%;object-fit:contain;pointer-events:none}
/* — scene presets in studio — */
.scene-presets{display:flex;gap:6px;flex-wrap:wrap}
.scene-presets button{font:inherit;font-size:12.5px;padding:6px 11px;border:1px solid var(--line);border-radius:8px;background:#fff;cursor:pointer;color:var(--muted)}
@@ -165,6 +169,12 @@
<div class="f">Mural
<select id="selMural"></select>
</div>
+ <div class="f">Mural width (ft)
+ <input id="murW" type="number" min="4" max="40" step="0.5" value="24" style="width:76px;font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px">
+ </div>
+ <div class="f">Mural height (ft)
+ <input id="murH" type="number" min="3" max="16" step="0.5" value="12" style="width:76px;font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px">
+ </div>
<div class="f">Wall width — <output id="wwOut"></output>
<input id="wallW" type="range" min="24" max="60" step="1" value="32">
</div>
@@ -180,7 +190,7 @@
</div>
<div class="scene" id="scene"></div>
<div class="readout" id="readout"></div>
- <div class="hint">Mural is fixed at 24 ft wide × 12 ft tall (150 DPI print master). Drag to set horizontal & vertical placement; it can't leave the wall.</div>
+ <div class="hint">Enter <b>your</b> mural size — the signatures re-arrange live to fill it (each signature prints ~2 ft wide at 150 DPI; a bigger wall shows more names). Drag to set placement; it can't leave the wall.</div>
</div>
</section>
@@ -331,6 +341,8 @@ async function boot(){
$('#selMural').addEventListener('change', e => selectMural(e.target.value));
$('#wallW').addEventListener('input', drawScene);
$('#wallH').addEventListener('input', drawScene);
+ $('#murW').addEventListener('input', centerMural);
+ $('#murH').addEventListener('input', centerMural);
$('#btnCenter').addEventListener('click', centerMural);
window.addEventListener('resize', drawScene);
@@ -414,9 +426,46 @@ function selectMural(code){
function wallDims(){ return { W:+$('#wallW').value, H:+$('#wallH').value }; }
+// ── custom mural size: the user enters width/height and the signature chips
+// re-arrange to fill it at true print scale (~1.9 × 0.85 ft per signature).
+// A bigger mural pulls the NEXT set of famous-first signatures into the grid.
+function murDims(){
+ return { w: Math.min(40, Math.max(4, +$('#murW').value || 24)),
+ h: Math.min(16, Math.max(3, +$('#murH').value || 12)) };
+}
+const CELL_FT_W = 1.9, CELL_FT_H = 0.85;
+// Commons direct URL → 200px thumbnail (SVGs rasterize server-side)
+function thumbUrl(u){
+ const m = String(u||'').match(/^(https?:\/\/upload\.wikimedia\.org\/wikipedia\/commons)\/([0-9a-f])\/([0-9a-f]{2})\/(.+)$/);
+ if (m) return `${m[1]}/thumb/${m[2]}/${m[3]}/${m[4]}/200px-${m[4]}${/\.svg$/i.test(m[4])?'.png':''}`;
+ return u;
+}
+function rosterFor(code){
+ const rows = (byMural[code]||[]).filter(r => r.signature_image_url);
+ if (rows.length) return rows;
+ // themed murals carry names only — resolve images from the archive by name
+ return (byMural[code]||[]).map(r => SIGS.find(s => s.full_name === r.full_name)).filter(Boolean);
+}
+function muralInnerHTML(pxPerFt){
+ const {w,h} = murDims();
+ const roster = rosterFor(cur.code);
+ if (!roster.length) return null; // fall back to the PNG
+ let cols = Math.max(1, Math.floor(w / CELL_FT_W));
+ let rows = Math.max(1, Math.floor(h / CELL_FT_H));
+ let n = Math.min(roster.length, cols * rows);
+ if (n < cols * rows) { // small roster → balance the grid
+ cols = Math.max(1, Math.min(cols, Math.ceil(Math.sqrt(n * (w/h) / (CELL_FT_W/CELL_FT_H)))));
+ rows = Math.ceil(n / cols);
+ }
+ const shown = roster.slice(0, n);
+ return { n, html: `<div class="sig-grid" style="grid-template-columns:repeat(${cols},1fr);grid-auto-rows:1fr">${
+ shown.map(r=>`<div class="sg-cell" title="${(r.full_name||'').replace(/"/g,'"')}"><img loading="lazy" src="${thumbUrl(r.signature_image_url)}" alt="${(r.full_name||'').replace(/"/g,'"')} signature" onerror="this.closest('.sg-cell').style.visibility='hidden'"></div>`).join('')
+ }</div>` };
+}
+
function centerMural(){
const {W,H} = wallDims();
- const mw = CAT.size.widthFt, mh = CAT.size.heightFt;
+ const {w:mw, h:mh} = murDims();
pos.x = Math.max(0,(W-mw)/2);
pos.y = Math.max(0,(H-mh)/2);
drawScene();
@@ -425,8 +474,9 @@ function centerMural(){
function drawScene(){
if(!cur) return;
const scene = $('#scene');
- const {W,H} = wallDims();
- const mw = CAT.size.widthFt, mh = CAT.size.heightFt;
+ let {W,H} = wallDims();
+ const {w:mw, h:mh} = murDims();
+ W = Math.max(W, mw); H = Math.max(H, mh); // wall always fits the mural
// scale: fit wall width to scene width; cap scene height sensibly
const sw = scene.clientWidth || 900;
let pxPerFt = sw / W;
@@ -457,16 +507,28 @@ function drawScene(){
background-image:url('/output/collage-${cur.slug}.png')">
<div class="grip">⟵ drag ⟶</div>
</div>`;
+ // live signature grid at the CUSTOM size (chips re-arrange as dims change)
+ const inner = muralInnerHTML(pxPerFt);
+ shownCount = inner ? inner.n : (cur.sigs || 0);
+ if (inner) {
+ const mel = $('#muralEl');
+ mel.style.backgroundImage = 'none';
+ mel.style.background = '#fff';
+ mel.insertAdjacentHTML('afterbegin', inner.html);
+ }
enableDrag(pxPerFt);
const floorGap = pos.y; // ft from mural bottom to floor
const leftGap = pos.x; // ft from left wall to mural
const rightGap = Math.max(0, W - mw - pos.x);
+ const sqft = Math.round(mw * mh);
$('#readout').innerHTML = `
<div>From left wall: <b>${leftGap.toFixed(1)}${FT}</b></div>
<div>From right wall: <b>${rightGap.toFixed(1)}${FT}</b></div>
<div>Bottom off floor: <b>${floorGap.toFixed(1)}${FT}</b></div>
- <div>Mural: <b>${mh}×${mw}${FT}</b></div>`;
+ <div>Mural: <b>${mh}×${mw}${FT}</b> · ${sqft} sq ft</div>
+ <div>Signatures shown: <b>${shownCount}</b></div>
+ <div>Price: <b>${money(CAT.pricePerSqFt * sqft)}</b></div>`;
syncOrderSummary();
}
@@ -495,8 +557,9 @@ function enableDrag(pxPerFt){
el.addEventListener('pointercancel', end);
}
+let shownCount = 0;
function liveReadout(){
- const {W}=wallDims(), mw=CAT.size.widthFt;
+ const {W}=wallDims(), mw=murDims().w;
$('#readout').children[0].querySelector('b').textContent = pos.x.toFixed(1)+FT;
$('#readout').children[1].querySelector('b').textContent = Math.max(0,W-mw-pos.x).toFixed(1)+FT;
$('#readout').children[2].querySelector('b').textContent = pos.y.toFixed(1)+FT;
@@ -505,9 +568,10 @@ function liveReadout(){
function syncOrderSummary(){
if(!cur) return;
const {W,H}=wallDims();
- const price = CAT.pricePerSqFt * CAT.size.sqft;
+ const {w:mw,h:mh}=murDims();
+ const price = CAT.pricePerSqFt * mw * mh;
$('#orderSummary').innerHTML =
- `<b>${cur.title}</b> · ${CAT.size.heightFt}×${CAT.size.widthFt}${FT} · ${money(price)}<br>
+ `<b>${cur.title}</b> · ${mh}×${mw}${FT} custom · ${shownCount} signatures · ${money(price)}<br>
Placement on a ${W}×${H}${FT} wall: ${pos.x.toFixed(1)}${FT} from left, ${pos.y.toFixed(1)}${FT} off floor.`;
// mirror into order form if blank
const f = $('#orderForm');
@@ -525,7 +589,8 @@ function wireOrder(){
name: f.name.value, email: f.email.value,
wall_w: +f.wall_w.value, wall_h: +f.wall_h.value,
placement: { from_left_ft:+pos.x.toFixed(2), off_floor_ft:+pos.y.toFixed(2) },
- size: CAT.size, price: CAT.pricePerSqFt*CAT.size.sqft,
+ mural_size: { widthFt: murDims().w, heightFt: murDims().h, signatures: shownCount },
+ size: CAT.size, price: CAT.pricePerSqFt * murDims().w * murDims().h,
notes: f.notes.value
};
try{
diff --git a/scripts/build-collages.py b/scripts/build-collages.py
index 2eaec24..4e24eb2 100644
--- a/scripts/build-collages.py
+++ b/scripts/build-collages.py
@@ -141,7 +141,7 @@ def fit(im):
# count → reliably scannable at a modest size, even printed and viewed at distance.
CODE = {
"declaration-of-independence": "d", "politics": "p", "sports": "s",
- "hollywood": "h", "movies-classic": "c", "tv": "t", "artists": "a",
+ "hollywood": "h", "movies-classic": "c", "tv": "t", "artists": "a", "authors": "w",
}
QR_PX = 180 # functional utility-mark size (~7px/module on the short /k/<code> URL)
@@ -287,7 +287,7 @@ def main():
by.setdefault(r["category"], []).append(r)
if safe_only:
print("⚖️ PUBLICITY-SAFE mode: only people who died ≤1956\n")
- order = ["Declaration of Independence", "Politics", "Sports", "Hollywood", "Movies (Classic)", "Movies", "TV", "Artists"]
+ order = ["Declaration of Independence", "Politics", "Sports", "Hollywood", "Movies (Classic)", "Movies", "TV", "Artists", "Authors"]
made = []
print(f"QR target host: {BASE_URL}\n")
for cat in order:
diff --git a/scripts/fetch-authors.mjs b/scripts/fetch-authors.mjs
new file mode 100644
index 0000000..c27bf2a
--- /dev/null
+++ b/scripts/fetch-authors.mjs
@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+// "Authors" category — famous deceased writers with REAL P109 signatures.
+// Same architecture as the Artists set: Wikidata universe by literary
+// occupation (writer, novelist, poet, playwright, essayist, short-story
+// writer, children's writer, science-fiction writer), deceased >50yr
+// (death < 1974, the site's publicity/copyright posture), famous-first by
+// cross-wiki sitelinks, every signature verified on Wikimedia Commons with a
+// license gate. Output: data/authors.json (regenerable; server merges it into
+// /api/signatures — the hand-curated file is never touched).
+// Dedupe: anyone already in celebrity_signatures.json or artists.json keeps
+// their existing category; they are skipped here.
+//
+// Usage: node scripts/fetch-authors.mjs
+// Cost: $0 — WDQS + Commons, no keys.
+
+import { readFile, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runSparql, fileTitleFromUrl, fetchLicenses, licenseRisk, assess, fetchLabels,
+ sleep,
+} from './lib/wikidata-common.mjs';
+
+const DATA = join(fileURLToPath(new URL('..', import.meta.url)), 'data');
+const DEATH_CUTOFF = 1974;
+const OCCUPATIONS = {
+ Q36180: 'writer', Q6625963: 'novelist', Q49757: 'poet', Q214917: 'playwright',
+ Q11774202: 'essayist', Q15949613: 'short-story writer',
+ Q4853732: "children's writer", Q18844224: 'science-fiction writer',
+};
+
+// ── 1) universe: deceased authors with a P109 signature ─────────────────────
+const byQid = new Map();
+for (const [occ, label] of Object.entries(OCCUPATIONS)) {
+ const q = `SELECT ?person ?personLabel ?sig ?dod ?links WHERE {
+ ?person wdt:P31 wd:Q5 ; wdt:P106 wd:${occ} ; wdt:P109 ?sig ; wdt:P570 ?dod ; wikibase:sitelinks ?links .
+ FILTER(YEAR(?dod) < ${DEATH_CUTOFF})
+ SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
+ }`;
+ process.stdout.write(`[authors] ${label} … `);
+ const rows = await runSparql(q);
+ let added = 0;
+ for (const b of rows) {
+ const qid = b.person.value.split('/').pop();
+ if (byQid.has(qid)) continue;
+ byQid.set(qid, {
+ qid,
+ name: b.personLabel?.value || qid,
+ sigUrl: b.sig?.value || null,
+ dod: b.dod?.value || null,
+ links: parseInt(b.links?.value || '0', 10),
+ role: label,
+ });
+ added++;
+ }
+ console.log(`${rows.length} rows (+${added}, total ${byQid.size})`);
+ await sleep(1500);
+}
+
+// ── 2) cross-category dedupe (curated + artists keep their people) ──────────
+const loadJson = async (f, fb) => { try { return JSON.parse(await readFile(join(DATA, f), 'utf8')); } catch { return fb; } };
+const qidOf = (r) => (r.wikidata || '').split('/').pop();
+const taken = new Set([
+ ...(await loadJson('celebrity_signatures.json', [])).map(qidOf),
+ ...(await loadJson('artists.json', [])).map(qidOf),
+].filter(Boolean));
+const candidates = [...byQid.values()].filter((p) => !taken.has(p.qid) && p.sigUrl && p.dod);
+console.log(`[authors] ${byQid.size} in universe → ${candidates.length} after cross-category dedupe`);
+
+// label backfill for raw QIDs (WDQS label-service flakiness)
+const needLabel = candidates.filter((p) => /^Q\d+$/.test(p.name)).map((p) => p.qid);
+if (needLabel.length) {
+ const labels = await fetchLabels(needLabel);
+ for (const p of candidates) if (labels[p.qid]) p.name = labels[p.qid];
+}
+
+// ── 3) Commons license verify (batched; PD/CC kept, unknown dropped) ────────
+const titles = [...new Set(candidates.map((p) => fileTitleFromUrl(p.sigUrl)).filter(Boolean))];
+console.log(`[authors] fetching ${titles.length} Commons licenses …`);
+const lic = await fetchLicenses(titles);
+
+candidates.sort((a, b) => b.links - a.links);
+const out = [];
+let droppedLic = 0;
+for (const p of candidates) {
+ const title = fileTitleFromUrl(p.sigUrl);
+ const meta = title ? lic[title] : null;
+ if (!meta || !meta.directUrl) { droppedLic++; continue; } // missing file
+ const license = meta.license || 'unknown';
+ if (licenseRisk(license) === 'high') { droppedLic++; continue; } // unknown license
+ const { risk_level, usable } = assess({ deceased: true, lic: license, fromArchive: false });
+ out.push({
+ category: 'Authors',
+ rank: out.length + 1,
+ full_name: p.name,
+ wikidata: `https://www.wikidata.org/wiki/${p.qid}`,
+ reason_for_ranking: `${p.role[0].toUpperCase() + p.role.slice(1)}; cross-wiki notability: present on ${p.links} language Wikipedias`,
+ ranking_source_urls: 'https://www.nobelprize.org/prizes/literature/ | https://www.loc.gov/',
+ signature_image_url: meta.directUrl,
+ signature_source_type: 'Wikimedia Commons (Wikidata P109 signature file)',
+ image_license: license,
+ image_author: meta.artist || '',
+ deceased: 'yes',
+ death_date: p.dod.slice(0, 10),
+ risk_level,
+ usable_in_commercial_collage: usable,
+ notes: `Deceased ${p.dod.slice(0, 4)} — low publicity-rights risk${/sa/i.test(license) && /cc by-?sa/i.test(license) ? '; CC BY-SA: derivative collage may need share-alike + attribution' : ''}`,
+ backup_source: meta.filePage || (title ? `https://commons.wikimedia.org/wiki/${encodeURIComponent(title)}` : ''),
+ });
+}
+
+await writeFile(join(DATA, 'authors.json'), JSON.stringify(out, null, 2));
+console.log(`[authors] wrote data/authors.json — ${out.length} authors (${droppedLic} dropped: missing file/unknown license)`);
+console.log('top 12:');
+out.slice(0, 12).forEach((r) => console.log(` ${r.rank}. ${r.full_name} (${r.death_date.slice(0, 4)}) — ${r.image_license}`));
diff --git a/server.js b/server.js
index f20e61f..47308b9 100644
--- a/server.js
+++ b/server.js
@@ -270,6 +270,7 @@ createServer(async (req, res) => {
if (path === '/api/signatures' && M === 'GET') {
const base = await load('celebrity_signatures.json', []);
const artists = await load('artists.json', []);
+ const authors = await load('authors.json', []); // generated 'Authors' set (fetch-authors.mjs)
const raw = await load('artists-raw.json', []);
// Occupation gate lives in the SERVER (not the data file) so the live grid
// stays correct no matter what a build/merge pipeline writes to artists.json.
@@ -282,7 +283,10 @@ createServer(async (req, res) => {
.filter(r => (r.occupations || []).some(o => ART_OCC.test(String(o).toLowerCase())))
.map(qid));
const cleanArtists = raw.length ? artists.filter(a => artistQids.has(qid(a))) : artists;
- const merged = [...base.filter(r => r.category !== 'Artists'), ...cleanArtists];
+ // Authors: same single-authority rule; artists win cross-category ties.
+ const seenQ = new Set([...base.map(qid), ...cleanArtists.map(qid)]);
+ const cleanAuthors = authors.filter(a => !seenQ.has(qid(a)));
+ const merged = [...base.filter(r => r.category !== 'Artists' && r.category !== 'Authors'), ...cleanArtists, ...cleanAuthors];
return sendJSON(res, 200, merged, { 'Cache-Control': 'no-cache' });
}
@@ -293,7 +297,7 @@ createServer(async (req, res) => {
if (path === '/account.js') path = '/public/account.js';
if (path === '/api/murals-catalog') path = '/data/murals-catalog.json';
if (path === '/api/signature-evolution') path = '/data/signature-evolution.json';
- const SHORT = { d: 'declaration-of-independence', p: 'politics', s: 'sports', h: 'hollywood', c: 'movies-classic', t: 'tv', o: 'oldest-signatures', a: 'artists' };
+ const SHORT = { d: 'declaration-of-independence', p: 'politics', s: 'sports', h: 'hollywood', c: 'movies-classic', t: 'tv', o: 'oldest-signatures', a: 'artists', w: 'authors' };
const km = path.match(/^\/k\/([a-z0-9-]+)$/);
if (km) path = `/output/collage-${SHORT[km[1]] || km[1]}-named.png`;
← 03c2beb Five-Horsemen UX pass (ui-ux + graphic-designer panel verdic
·
back to CelebritySignatures
·
Mural pricing: $10/sq ft (Steve directive) — 24x12 flagship 9aa908e →