← 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
M public/index.htmlM scripts/build-collages.pyA scripts/fetch-signature-evolution.mjsM server.jsD tmp_artists.png
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 →