Captura integral do acervo da Brasiliana Fotográfica (Biblioteca Nacional) para s3://brasiliana: 13.060 itens, 12.985 com imagem de preservação, 13.059 com miniatura derivada, 2.137 cópias legadas e 636 posts do blog. 41.444 objetos no ledger. Os 75 itens sem preservação estão em errors.md: a página do item responde 200, mas a origem serve o bitstream com HTTP 500. Nenhum fica fora do mural, porque todos têm thumbnail. Não versionado, por viver no bucket ou ser reconstruível: catálogo, posts .jsonl, atlas .webp, sqlite de estado e logs. Credenciais ficam de fora pelo .gitignore.
334 lines
13 KiB
HTML
334 lines
13 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="pt-BR">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Brasiliana — Linha do Tempo</title>
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
html, body { width: 100%; height: 100%; background: #000; color: #e8e8e0; font-family: system-ui, -apple-system, sans-serif; }
|
||
body { overflow: hidden; }
|
||
|
||
#title-bar {
|
||
position: fixed; top: 0; left: 0; right: 0; height: 40px; z-index: 30;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 0 16px; background: #000; border-bottom: 1px solid #222;
|
||
font-size: .82rem; letter-spacing: .04em;
|
||
}
|
||
#title-bar .side { color: #888; font-variant-numeric: tabular-nums; }
|
||
#title-bar .mid { font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: #ddd; }
|
||
|
||
#scroll {
|
||
position: fixed; top: 40px; left: 0; right: 0; bottom: 0;
|
||
overflow: auto;
|
||
}
|
||
|
||
#grid { position: relative; }
|
||
|
||
.row-label {
|
||
position: sticky; left: 0; z-index: 5;
|
||
background: #000;
|
||
display: flex; align-items: center;
|
||
padding: 0 10px;
|
||
font-size: .68rem; letter-spacing: .03em; color: #ddd;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
border-right: 1px solid #1c1c1c;
|
||
}
|
||
.row-label.dim { color: #777; font-style: italic; }
|
||
|
||
.row-chart { position: relative; border-bottom: 1px solid #141414; }
|
||
.row-chart:hover { background: rgba(255,255,255,.02); }
|
||
|
||
.lifeline { position: absolute; height: 1px; background: #555; top: 50%; }
|
||
.dot { position: absolute; width: 5px; height: 5px; border-radius: 50%; background: #888; top: 50%; transform: translate(-50%, -50%); }
|
||
|
||
.thumb {
|
||
position: absolute; top: 50%; transform: translateY(-50%);
|
||
cursor: pointer; border: 1px solid rgba(0,0,0,.6);
|
||
box-shadow: 0 0 0 1px rgba(255,255,255,.08);
|
||
transition: transform .12s ease, box-shadow .12s ease, z-index 0s;
|
||
}
|
||
.thumb:hover {
|
||
transform: translateY(-50%) scale(2.4);
|
||
z-index: 6;
|
||
box-shadow: 0 4px 18px rgba(0,0,0,.7), 0 0 0 1px rgba(255,255,255,.4);
|
||
}
|
||
|
||
/* axis header */
|
||
.axis-corner {
|
||
position: sticky; top: 0; left: 0; z-index: 12;
|
||
background: #000; border-right: 1px solid #1c1c1c; border-bottom: 1px solid #222;
|
||
display: flex; align-items: flex-end; padding: 0 10px 6px;
|
||
font-size: .62rem; letter-spacing: .08em; color: #666; text-transform: uppercase;
|
||
}
|
||
.axis-row {
|
||
position: sticky; top: 0; z-index: 8;
|
||
background: #000; border-bottom: 1px solid #222;
|
||
position: relative;
|
||
}
|
||
.tick { position: absolute; bottom: 0; width: 1px; background: #333; }
|
||
.tick.major { background: #555; }
|
||
.tick-label { position: absolute; bottom: 18px; transform: translateX(-50%); font-size: .62rem; color: #777; font-variant-numeric: tabular-nums; }
|
||
|
||
#loading {
|
||
position: fixed; inset: 0; background: #000; z-index: 100;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: .78rem; letter-spacing: .12em; text-transform: uppercase; color: #888;
|
||
transition: opacity .5s ease;
|
||
}
|
||
|
||
/* ── metadata card (same language as wall.html) ─────────────────────── */
|
||
#card-wrap {
|
||
position: fixed; inset: 0; z-index: 50;
|
||
display: flex; align-items: center; justify-content: center;
|
||
opacity: 0; pointer-events: none; transition: opacity .3s ease;
|
||
background: rgba(0,0,0,.55);
|
||
}
|
||
#card-wrap.open { opacity: 1; pointer-events: auto; }
|
||
#card {
|
||
position: relative; width: min(420px, 90vw); max-height: 86vh; overflow-y: auto;
|
||
background: rgba(10,10,10,.92); backdrop-filter: blur(6px); border-radius: 6px;
|
||
padding: 34px 30px 26px; text-align: center; box-shadow: 0 30px 80px rgba(0,0,0,.7);
|
||
font-family: Georgia, 'Times New Roman', serif;
|
||
transform: translateY(10px) scale(.97); transition: transform .3s ease;
|
||
}
|
||
#card-wrap.open #card { transform: translateY(0) scale(1); }
|
||
#card-back {
|
||
position: absolute; left: 14px; top: 14px; width: 30px; height: 30px; border-radius: 50%;
|
||
background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.18); color: #ddd;
|
||
font-size: 1rem; line-height: 28px; text-align: center; cursor: pointer; font-family: system-ui, sans-serif;
|
||
}
|
||
#card-back:hover { background: rgba(255,255,255,.18); }
|
||
#card-type { font-size: 1.5rem; font-weight: 400; color: #f2f2f2; margin-bottom: 6px; }
|
||
#card-seealso { font-size: .62rem; letter-spacing: .12em; text-transform: uppercase; color: #7fa8c9; margin-bottom: 18px; }
|
||
#card-img-wrap { margin: 0 auto 18px; max-width: 260px; }
|
||
#card-img { display: block; width: 100%; height: auto; border-radius: 2px; box-shadow: 0 12px 40px rgba(0,0,0,.55); }
|
||
#card-title { font-size: 1.02rem; color: #f5f5f0; }
|
||
#card-date { font-size: .85rem; color: #b9b9b0; margin-top: 2px; }
|
||
#card-contrib { font-size: .82rem; color: #cdd8e0; margin-top: 10px; }
|
||
#card-footer { margin-top: 16px; padding-top: 14px; border-top: 1px solid rgba(255,255,255,.12); font-family: system-ui, sans-serif; font-size: .68rem; letter-spacing: .04em; color: #888; }
|
||
#card-footer a { color: #d8b04a; text-decoration: none; }
|
||
#card-footer a:hover { text-decoration: underline; }
|
||
#card-rights { margin-top: 6px; font-size: .68rem; color: #777; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="loading">carregando linha do tempo…</div>
|
||
|
||
<div id="title-bar">
|
||
<span class="side" id="year-left">—</span>
|
||
<span class="mid">Autoria — Brasiliana Fotográfica</span>
|
||
<span class="side" id="year-right">—</span>
|
||
</div>
|
||
|
||
<div id="scroll"><div id="grid"></div></div>
|
||
|
||
<div id="card-wrap">
|
||
<div id="card">
|
||
<div id="card-back">←</div>
|
||
<div id="card-type">—</div>
|
||
<div id="card-seealso"><span id="card-community">—</span> · <span id="card-date-tag">—</span></div>
|
||
<div id="card-img-wrap"><img id="card-img" alt=""></div>
|
||
<div id="card-title">—</div>
|
||
<div id="card-date">—</div>
|
||
<div id="card-contrib"></div>
|
||
<div id="card-footer">
|
||
<div><a id="card-link" href="#" target="_blank" rel="noopener">ver item original ↗</a> · <span id="card-handle"></span></div>
|
||
<div id="card-rights"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
/*
|
||
* wall2.html — "Gestalter"-style timeline, uma linha por autor/fotógrafo.
|
||
* Baseado num diagrama clássico de história do design gráfico (Gestalter,
|
||
* 1925-2011): uma linha horizontal por pessoa cobrindo seu período de
|
||
* atividade conhecido, com miniaturas de cada obra plotadas no ano exato.
|
||
*
|
||
* Aqui a "pessoa" é o DC.contributor de cada item (o fotógrafo/autor, ou
|
||
* a instituição quando é essa a autoria registrada), e a "obra" é a foto.
|
||
* Sem data de nascimento/morte real: a linha vai do ano mais antigo ao
|
||
* mais recente entre as fotos DATADAS desse autor no catálogo — é um
|
||
* proxy honesto do período ativo, não uma biografia.
|
||
*
|
||
* Reaproveita o MESMO atlas de wall.html (atlas/atlas-map.json +
|
||
* atlas-0.webp) — nenhum build novo é necessário.
|
||
*/
|
||
|
||
const PX_PER_YEAR = 16;
|
||
const ROW_H = 32;
|
||
const THUMB = 20;
|
||
const LABEL_W = 190;
|
||
const AXIS_H = 34;
|
||
|
||
function yearOf(dateStr) {
|
||
if (!dateStr) return null;
|
||
const m = String(dateStr).match(/(1[5-9]\d{2}|20\d{2})/);
|
||
return m ? parseInt(m[1], 10) : null;
|
||
}
|
||
|
||
let atlasEntries = null, TPR = 1, atlasImg = null;
|
||
const bgTotal = {}; // computed per-thumb background-size string, cached
|
||
|
||
function thumbEl(entry, xPx) {
|
||
const el = document.createElement('div');
|
||
el.className = 'thumb';
|
||
el.style.left = `${xPx}px`;
|
||
el.style.width = `${THUMB}px`;
|
||
el.style.height = `${THUMB}px`;
|
||
el.style.backgroundImage = `url(atlas/atlas-0.webp)`;
|
||
el.style.backgroundSize = `${TPR * THUMB}px ${TPR * THUMB}px`;
|
||
el.style.backgroundPosition = `-${entry.col * THUMB}px -${entry.row * THUMB}px`;
|
||
el.addEventListener('click', () => showCard(entry));
|
||
return el;
|
||
}
|
||
|
||
function showCard(entry) {
|
||
const meta = entry.meta;
|
||
document.getElementById('card-type').textContent = meta.type || 'Fotografia';
|
||
document.getElementById('card-community').textContent = meta.community || '—';
|
||
document.getElementById('card-date-tag').textContent = meta.date || 's.d.';
|
||
document.getElementById('card-title').textContent = meta.title || '(sem título)';
|
||
document.getElementById('card-date').textContent = meta.date || '';
|
||
document.getElementById('card-contrib').textContent = meta.contributor || '';
|
||
document.getElementById('card-handle').textContent = meta.handle || '';
|
||
document.getElementById('card-link').href = meta.source_url || '#';
|
||
document.getElementById('card-rights').textContent = meta.rights ? `Direitos: ${meta.rights}` : '';
|
||
|
||
const img = document.getElementById('card-img');
|
||
if (atlasImg) {
|
||
const S = atlasImg.naturalWidth / TPR;
|
||
const c = document.createElement('canvas');
|
||
c.width = c.height = 512;
|
||
const ctx = c.getContext('2d');
|
||
ctx.drawImage(atlasImg, entry.col * S, entry.row * S, S, S, 0, 0, 512, 512);
|
||
img.src = c.toDataURL('image/jpeg', 0.92);
|
||
}
|
||
document.getElementById('card-wrap').classList.add('open');
|
||
}
|
||
function closeCard() { document.getElementById('card-wrap').classList.remove('open'); }
|
||
document.getElementById('card-back').addEventListener('click', closeCard);
|
||
document.getElementById('card-wrap').addEventListener('click', e => { if (e.target.id === 'card-wrap') closeCard(); });
|
||
window.addEventListener('keydown', e => { if (e.key === 'Escape') closeCard(); });
|
||
|
||
async function main() {
|
||
const mapData = await fetch('atlas/atlas-map.json').then(r => r.json());
|
||
TPR = mapData.size / mapData.tile;
|
||
atlasEntries = mapData.map;
|
||
|
||
// ── group items by author, keep only ones with a parseable year ──────────
|
||
const byAuthor = new Map();
|
||
let globalMin = Infinity, globalMax = -Infinity;
|
||
for (const [handle, entry] of Object.entries(mapData.map)) {
|
||
const meta = entry.meta;
|
||
const y = yearOf(meta.date);
|
||
const author = meta.contributor;
|
||
if (!author || y == null) continue;
|
||
if (!byAuthor.has(author)) byAuthor.set(author, []);
|
||
byAuthor.get(author).push({ ...entry, year: y });
|
||
if (y < globalMin) globalMin = y;
|
||
if (y > globalMax) globalMax = y;
|
||
}
|
||
const authors = [...byAuthor.keys()].sort((a, b) => a.localeCompare(b, 'pt-BR'));
|
||
authors.forEach(a => byAuthor.get(a).sort((x, y) => x.year - y.year));
|
||
|
||
const axisMin = Math.floor(globalMin / 5) * 5 - 5;
|
||
const axisMax = Math.ceil(globalMax / 5) * 5 + 5;
|
||
const totalWidth = (axisMax - axisMin) * PX_PER_YEAR;
|
||
const yearToX = y => (y - axisMin) * PX_PER_YEAR;
|
||
|
||
document.getElementById('year-left').textContent = globalMin;
|
||
document.getElementById('year-right').textContent = globalMax;
|
||
|
||
// ── build the grid: corner, axis row, then one label+chart row per author ──
|
||
const grid = document.getElementById('grid');
|
||
grid.style.display = 'grid';
|
||
grid.style.gridTemplateColumns = `${LABEL_W}px ${totalWidth}px`;
|
||
grid.style.gridAutoRows = 'auto';
|
||
|
||
const corner = document.createElement('div');
|
||
corner.className = 'axis-corner';
|
||
corner.style.height = `${AXIS_H}px`;
|
||
corner.textContent = `${authors.length} autores`;
|
||
grid.appendChild(corner);
|
||
|
||
const axisRow = document.createElement('div');
|
||
axisRow.className = 'axis-row';
|
||
axisRow.style.height = `${AXIS_H}px`;
|
||
axisRow.style.width = `${totalWidth}px`;
|
||
for (let y = axisMin; y <= axisMax; y++) {
|
||
const major = y % 5 === 0;
|
||
const tick = document.createElement('div');
|
||
tick.className = 'tick' + (major ? ' major' : '');
|
||
tick.style.left = `${yearToX(y)}px`;
|
||
tick.style.height = major ? '10px' : '5px';
|
||
axisRow.appendChild(tick);
|
||
if (major) {
|
||
const lbl = document.createElement('div');
|
||
lbl.className = 'tick-label';
|
||
lbl.style.left = `${yearToX(y)}px`;
|
||
lbl.textContent = y;
|
||
axisRow.appendChild(lbl);
|
||
}
|
||
}
|
||
grid.appendChild(axisRow);
|
||
|
||
for (const author of authors) {
|
||
const items = byAuthor.get(author);
|
||
const minY = items[0].year, maxY = items[items.length - 1].year;
|
||
|
||
const label = document.createElement('div');
|
||
label.className = 'row-label' + (/^(Anônimo|Autoria não identificada|s\/autor)$/i.test(author) ? ' dim' : '');
|
||
label.style.height = `${ROW_H}px`;
|
||
label.textContent = author;
|
||
label.title = `${author} · ${items.length} obra(s) · ${minY}–${maxY}`;
|
||
grid.appendChild(label);
|
||
|
||
const chart = document.createElement('div');
|
||
chart.className = 'row-chart';
|
||
chart.style.height = `${ROW_H}px`;
|
||
chart.style.width = `${totalWidth}px`;
|
||
|
||
const x0 = yearToX(minY), x1 = yearToX(maxY);
|
||
const line = document.createElement('div');
|
||
line.className = 'lifeline';
|
||
line.style.left = `${x0}px`;
|
||
line.style.width = `${Math.max(1, x1 - x0)}px`;
|
||
chart.appendChild(line);
|
||
[x0, x1].forEach(x => {
|
||
const d = document.createElement('div');
|
||
d.className = 'dot';
|
||
d.style.left = `${x}px`;
|
||
chart.appendChild(d);
|
||
});
|
||
|
||
// spread out thumbnails that would otherwise overlap at the same year
|
||
let lastX = -Infinity;
|
||
for (const it of items) {
|
||
let x = yearToX(it.year) - THUMB / 2;
|
||
if (x < lastX + THUMB * 0.9) x = lastX + THUMB * 0.9;
|
||
lastX = x;
|
||
chart.appendChild(thumbEl(it, x));
|
||
}
|
||
|
||
grid.appendChild(chart);
|
||
}
|
||
|
||
atlasImg = new Image();
|
||
atlasImg.src = 'atlas/atlas-0.webp';
|
||
await atlasImg.decode().catch(() => {});
|
||
|
||
document.getElementById('loading').style.opacity = '0';
|
||
setTimeout(() => document.getElementById('loading').remove(), 500);
|
||
}
|
||
|
||
main().catch(err => {
|
||
document.getElementById('loading').textContent = `erro: ${err.message}`;
|
||
console.error(err);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|