[object Object]

← 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

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&nbsp;ft wide × 12&nbsp;ft tall (150&nbsp;DPI print master). Drag to set horizontal &amp; 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&nbsp;ft wide at 150&nbsp;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,'&quot;')}"><img loading="lazy" src="${thumbUrl(r.signature_image_url)}" alt="${(r.full_name||'').replace(/"/g,'&quot;')} 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 →