[object Object]

← back to CelebritySignatures

Signature evolution: multi-P109 + Commons 'Signatures of X' timeline fetcher, inline card strip (details/summary, no modal); wire Artists into collage builder + /k/a QR short code (TK-10181)

757e39d286392745cc6bf3a840445da1f005e2ad · 2026-08-03 10:27:01 -0700 · Steve Abrams

Files touched

Diff

commit 757e39d286392745cc6bf3a840445da1f005e2ad
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 3 10:27:01 2026 -0700

    Signature evolution: multi-P109 + Commons 'Signatures of X' timeline fetcher, inline card strip (details/summary, no modal); wire Artists into collage builder + /k/a QR short code (TK-10181)
---
 public/index.html                     |  23 +++++++
 scripts/build-collages.py             |   4 +-
 scripts/fetch-signature-evolution.mjs | 120 ++++++++++++++++++++++++++++++++++
 server.js                             |   3 +-
 tmp_artists.png                       | Bin 343886 -> 0 bytes
 5 files changed, 147 insertions(+), 3 deletions(-)

diff --git a/public/index.html b/public/index.html
index 65259e3..20c0cea 100644
--- a/public/index.html
+++ b/public/index.html
@@ -45,6 +45,15 @@
   .collage .ch a { margin-left:auto; font-size:13px; color:#1d7a36; text-decoration:none; }
   .collage img { width:100%; display:block; }
   .collage .note { padding:24px 18px; color:var(--muted); font-size:14px; }
+  /* signature-evolution: inline expand-in-flow strip (never a modal) */
+  .evo { margin-top:2px; }
+  .evo summary { cursor:pointer; font-size:11.5px; color:#1d7a36; list-style:none; }
+  .evo summary::-webkit-details-marker { display:none; }
+  .evo[open] summary { color:var(--muted); }
+  .evo-strip { display:flex; flex-wrap:wrap; gap:8px; padding:8px 0 2px; }
+  .evo-cell { text-align:center; }
+  .evo-cell img { max-height:34px; max-width:110px; object-fit:contain; background:#fff; border:1px solid var(--line); border-radius:6px; padding:3px; }
+  .evo-cell .y { font-size:10px; color:var(--muted); }
 </style>
 </head>
 <body>
@@ -130,6 +139,7 @@ function render() {
         <div class="row"><span class="tag">${drill('cat',r.category)}</span><span class="badge b-${r.risk_level}">${r.risk_level}</span><span class="badge ${uClass}">${drill('use',ub,uText)}</span></div>
         <div class="sub">${r.deceased==='yes'?'† ':'● living · '}${(r.image_license||'').slice(0,28)}</div>
         ${Array.isArray(r.museums)&&r.museums.length?`<div class="sub" title="${esc(r.museums.join(' · '))}">🏛 ${esc(r.museums.slice(0,2).join(' · '))}${r.museums.length>2?` +${r.museums.length-2}`:''}</div>`:''}
+        ${evoStrip(r)}
         <div class="sub"><a href="${r.wikidata}" target="_blank" rel="noopener noreferrer">Wikidata</a> · <a href="${r.backup_source}" target="_blank" rel="noopener noreferrer">source</a></div>
       </div></div>`;
   }).join('');
@@ -181,7 +191,20 @@ function setView(v) {
 }
 document.querySelector('.tabs').onclick = (e) => { const v=e.target.dataset.v; if(v) setView(v); };
 
+// signature evolution: people with multiple signature files over their life.
+// Rendered as an inline <details> strip on the card (expand-in-flow, no modal).
+let EVO = {};
+function evoStrip(r){
+  const qid=(r.wikidata||'').split('/').pop(), e=EVO[qid];
+  if(!e||!e.sigs||e.sigs.length<2) return '';
+  const dated=e.sigs.filter(s=>s.year);
+  const label=`✍ ${e.sigs.length} signatures over time${dated.length>=2?` (${dated[0].year}–${dated[dated.length-1].year})`:''}`;
+  return `<details class="evo"><summary>${label}</summary><div class="evo-strip">${
+    e.sigs.map(s=>`<div class="evo-cell"><img loading="lazy" src="${esc(s.url)}" alt="Signature of ${esc(e.name)}${s.year?' ('+s.year+')':''}" onerror="this.closest('.evo-cell').remove()"><div class="y">${s.year||'undated'}</div></div>`).join('')
+  }</div></details>`;
+}
 fetch('/api/signatures').then(r=>r.json()).then(d => { DATA=d; readURL(); buildChips(); render(); setView(prefs.view); });
+fetch('/api/signature-evolution').then(r=>r.ok?r.json():{}).then(e=>{ EVO=e||{}; if(DATA.length) render(); }).catch(()=>{});
 </script>
 </body>
 </html>
diff --git a/scripts/build-collages.py b/scripts/build-collages.py
index 2ac15a1..458a019 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",
+    "hollywood": "h", "movies-classic": "c", "tv": "t", "artists": "a",
 }
 QR_PX = 180   # functional utility-mark size (~7px/module on the short /k/<code> URL)
 
@@ -281,7 +281,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"]
+    order = ["Declaration of Independence", "Politics", "Sports", "Hollywood", "Movies (Classic)", "Movies", "TV", "Artists"]
     made = []
     print(f"QR target host: {BASE_URL}\n")
     for cat in order:
diff --git a/scripts/fetch-signature-evolution.mjs b/scripts/fetch-signature-evolution.mjs
new file mode 100644
index 0000000..da081e5
--- /dev/null
+++ b/scripts/fetch-signature-evolution.mjs
@@ -0,0 +1,120 @@
+#!/usr/bin/env node
+// Signature EVOLUTION — "did this person's signature change over time?"
+// For every person in data/celebrity_signatures.json, collect ALL known
+// signature files (not just the one shown on the card):
+//   1. every Wikidata P109 statement, with point-in-time qualifiers
+//      (P585 point-in-time, P580 start-time) — some people have several
+//   2. Wikimedia Commons "Category:Signatures of <name>" members (the Commons
+//      convention for multi-era signature sets, e.g. Picasso)
+// Years come from qualifiers first, then a year token in the filename.
+// Emits data/signature-evolution.json for people with >=2 distinct files:
+//   { <qid>: { name, sigs: [{file, url, year, source, license}] } }
+// Display-only data (the storefront card signature + collage sourcing are
+// unchanged); licenses are recorded per file for future commercial gating.
+//
+// Usage: node scripts/fetch-signature-evolution.mjs
+// Cost: $0 — WDQS + Commons APIs. Caches per-person category lookups.
+
+import { readFile, writeFile, mkdir } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { UA, COMMONS, runSparql, fetchLicenses, sleep } from './lib/wikidata-common.mjs';
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url));
+const DATA = join(ROOT, 'data');
+const CACHE = join(ROOT, 'tmp_artists_cache');
+const YEAR_RE = /\b(1[4-9]\d\d|20\d\d)\b/;
+
+const people = JSON.parse(await readFile(join(DATA, 'celebrity_signatures.json'), 'utf8'))
+  .map((r) => ({ qid: (r.wikidata || '').split('/').pop(), name: r.full_name }))
+  .filter((p) => /^Q\d+$/.test(p.qid));
+const uniq = [...new Map(people.map((p) => [p.qid, p])).values()];
+console.log(`${uniq.length} people to sweep`);
+
+// ── 1) all P109 statements + time qualifiers (batched SPARQL) ───────────────
+const sigsByQid = new Map(); // qid -> Map(file -> {file, year, source})
+const claim = (qid, file, year, source) => {
+  if (!file) return;
+  if (!sigsByQid.has(qid)) sigsByQid.set(qid, new Map());
+  const m = sigsByQid.get(qid);
+  const cur = m.get(file);
+  if (cur) { if (year && !cur.year) cur.year = year; }
+  else m.set(file, { file, year: year || null, source });
+};
+
+for (let i = 0; i < uniq.length; i += 300) {
+  const batch = uniq.slice(i, i + 300);
+  const q = `SELECT ?person ?sig ?pit ?start WHERE {
+    VALUES ?person { ${batch.map((p) => `wd:${p.qid}`).join(' ')} }
+    ?person p:P109 ?st . ?st ps:P109 ?sig .
+    OPTIONAL { ?st pq:P585 ?pit } OPTIONAL { ?st pq:P580 ?start }
+  }`;
+  const rows = await runSparql(q);
+  for (const b of rows) {
+    const qid = b.person.value.split('/').pop();
+    const m = (b.sig?.value || '').match(/Special:FilePath\/(.+)$/);
+    const file = m ? decodeURIComponent(m[1]).replace(/_/g, ' ') : null;
+    const t = b.pit?.value || b.start?.value || null;
+    const year = t ? +t.slice(0, 4) : (file && YEAR_RE.test(file) ? +file.match(YEAR_RE)[1] : null);
+    claim(qid, file, year, 'p109');
+  }
+  console.log(`[p109] batch ${i / 300 + 1}/${Math.ceil(uniq.length / 300)} — ${rows.length} statements`);
+  await sleep(1200);
+}
+
+// ── 2) Commons "Category:Signatures of <name>" sweep (cached) ───────────────
+await mkdir(CACHE, { recursive: true });
+const catCachePath = join(CACHE, 'evo-commons-cats.json');
+const catCache = existsSync(catCachePath) ? JSON.parse(await readFile(catCachePath, 'utf8')) : {};
+let swept = 0, catHits = 0;
+for (const p of uniq) {
+  if (!(p.qid in catCache)) {
+    const title = `Category:Signatures of ${p.name}`;
+    const url = `${COMMONS}?action=query&format=json&list=categorymembers&cmtitle=${encodeURIComponent(title)}&cmtype=file&cmlimit=100&origin=*`;
+    try {
+      const res = await fetch(url, { headers: { 'User-Agent': UA } });
+      const j = res.ok ? await res.json() : null;
+      catCache[p.qid] = (j?.query?.categorymembers || []).map((m) => m.title.replace(/^File:/, ''));
+    } catch { catCache[p.qid] = []; }
+    await sleep(120);
+    if (++swept % 100 === 0) {
+      await writeFile(catCachePath, JSON.stringify(catCache));
+      process.stdout.write(`[commons] ${swept} swept\r`);
+    }
+  }
+  for (const file of catCache[p.qid]) {
+    const year = YEAR_RE.test(file) ? +file.match(YEAR_RE)[1] : null;
+    claim(p.qid, file, year, 'commons-cat');
+  }
+  if (catCache[p.qid].length) catHits++;
+}
+await writeFile(catCachePath, JSON.stringify(catCache));
+console.log(`\n[commons] ${catHits} people have a "Signatures of <name>" category`);
+
+// ── 3) keep people with >=2 distinct files; fetch licenses ──────────────────
+const multi = [...sigsByQid.entries()].filter(([, m]) => m.size >= 2);
+const allFiles = [...new Set(multi.flatMap(([, m]) => [...m.keys()]))].map((f) => 'File:' + f);
+console.log(`${multi.length} people with >=2 signature files (${allFiles.length} files) — fetching licenses`);
+const lic = await fetchLicenses(allFiles);
+
+const nameByQid = new Map(uniq.map((p) => [p.qid, p.name]));
+const out = {};
+for (const [qid, m] of multi) {
+  const sigs = [...m.values()]
+    .map((s) => ({
+      ...s,
+      url: `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(s.file)}?width=400`,
+      license: lic['File:' + s.file]?.license || 'unknown',
+    }))
+    .sort((a, b) => (a.year ?? 9999) - (b.year ?? 9999));
+  out[qid] = { name: nameByQid.get(qid), sigs };
+}
+await writeFile(join(DATA, 'signature-evolution.json'), JSON.stringify(out, null, 1));
+
+const dated = Object.values(out).filter((o) => o.sigs.filter((s) => s.year).length >= 2);
+console.log(`wrote data/signature-evolution.json — ${Object.keys(out).length} people with multiple signatures, ${dated.length} with a datable timeline`);
+console.log('top timelines:');
+Object.values(out)
+  .sort((a, b) => b.sigs.length - a.sigs.length).slice(0, 10)
+  .forEach((o) => console.log(`  ${o.name}: ${o.sigs.length} signatures ${o.sigs.filter((s) => s.year).length ? `(${o.sigs.filter((s) => s.year).map((s) => s.year).join(', ')})` : '(undated)'}`));
diff --git a/server.js b/server.js
index 3d7e5c2..e823d05 100644
--- a/server.js
+++ b/server.js
@@ -162,7 +162,8 @@ createServer(async (req, res) => {
     if (path === '/murals') path = '/public/murals.html';
     if (path === '/account.js') path = '/public/account.js';
     if (path === '/api/murals-catalog') path = '/data/murals-catalog.json';
-    const SHORT = { d: 'declaration-of-independence', p: 'politics', s: 'sports', h: 'hollywood', c: 'movies-classic', t: 'tv', o: 'oldest-signatures' };
+    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 km = path.match(/^\/k\/([a-z0-9-]+)$/);
     if (km) path = `/output/collage-${SHORT[km[1]] || km[1]}-named.png`;
 
diff --git a/tmp_artists.png b/tmp_artists.png
deleted file mode 100644
index c445644..0000000
Binary files a/tmp_artists.png and /dev/null differ

← e3ae265 auto-save: 2026-08-03T10:23:02 (3 files) — data/artists-raw.  ·  back to CelebritySignatures  ·  3 museum mural themes: Masters of the MET / National Gallery 396705f →