Files
brasiliano/web/wall.html
T

1969 lines
91 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Brasiliana Fotográfica — Mural do Acervo</title>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js"
}
}
</script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%; height: 100dvh; overflow: hidden; background: #0b0b0c;
font-family: Georgia, 'Times New Roman', serif; color: #eee;
overscroll-behavior: none; /* no rubber-banding the page behind the wall */
-webkit-tap-highlight-color: transparent;
-webkit-text-size-adjust: 100%;
}
#canvas { display: block; width: 100vw; height: 100vh; height: 100dvh; touch-action: none; }
#loading {
position: fixed; inset: 0; background: #0b0b0c;
display: flex; align-items: center; justify-content: center; z-index: 100;
/* linear, not ease: an eased fade is perceptually over in half its time */
transition: opacity .9s linear, background-color 1.4s ease;
}
#loading.ready { background: rgba(11,11,12,.78); } /* the wall renders live behind the title card */
/* scoped to the id, not to every <p> under the cover — the title screen's
own subtitle is a <p> too, and it is not an uppercase tracking-out line */
#loading-msg { font-size: .8rem; letter-spacing: .15em; text-transform: uppercase; color: #999; }
/* ── opening title screen (shown once data is ready, before entering) ────
It overlays the cover rather than sitting beside the loading line: as a
flex sibling it still claimed its share of the row while invisible, which
squeezed "carregando mural…" into a narrow column down one side — the
first thing anyone saw, and it read as a page with no stylesheet. */
#title-screen {
position: absolute; inset: 0;
display: flex; flex-direction: column; justify-content: center;
text-align: center;
padding: 0 24px;
padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom);
opacity: 0; filter: blur(0px); transform: scale(1);
transition: opacity .9s ease;
}
#title-screen.show { opacity: 1; }
/* the words go before the dark does: they blur out and drift open, the way a
memory clears — and the veil only lifts once they're gone */
#title-screen.dissolve {
opacity: 0; filter: blur(8px); transform: scale(1.04);
transition: opacity .85s linear, filter .85s ease-in, transform 1.1s ease-out;
}
#title-screen .eyebrow {
font-family: system-ui, sans-serif; font-size: .68rem; letter-spacing: .28em;
text-transform: uppercase; color: #d8b04a; margin-bottom: 22px;
}
#title-screen h1 {
font-family: Georgia, 'Times New Roman', serif; font-weight: 400;
font-size: clamp(2rem, 6vw, 3.6rem); letter-spacing: .01em; color: #f4f1e8;
line-height: 1.15;
}
#title-screen .sub {
margin-top: 16px; font-family: Georgia, serif; font-style: italic;
font-size: clamp(.95rem, 2vw, 1.15rem); color: #b9b9b0;
}
#title-screen .stats {
margin-top: 34px; display: flex; gap: 28px; justify-content: center; flex-wrap: wrap;
font-family: system-ui, sans-serif;
}
#title-screen .stat { text-align: center; }
#title-screen .stat b {
display: block; font-size: 1.3rem; color: #f4f1e8; font-weight: 600;
font-variant-numeric: tabular-nums;
}
#title-screen .stat span {
display: block; margin-top: 3px; font-size: .62rem; letter-spacing: .1em;
text-transform: uppercase; color: #888;
}
#title-screen .enter {
margin-top: 46px; font-family: system-ui, sans-serif; font-size: .68rem;
letter-spacing: .12em; text-transform: uppercase; color: #7fa8c9;
}
#title-screen .enter.ready { animation: pulse 2.4s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
#progress-track {
margin: 14px auto 0; width: 180px; height: 2px; background: rgba(255,255,255,.12);
border-radius: 2px; overflow: hidden;
}
#progress-bar { height: 100%; width: 0%; background: #7fa8c9; transition: width .2s ease; }
[hidden] { display: none !important; }
/* shown instead of a frozen bar: a load that dies or stalls says so, and the
retry is the reader's call — never an automatic reload, which on a phone
that just ran out of memory is exactly how the loop starts */
#falha {
position: fixed; inset: 0; z-index: 200; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 14px; padding: 0 24px;
background: rgba(11,11,12,.94); color: #ddd; text-align: center;
font-family: system-ui, sans-serif; font-size: .9rem; line-height: 1.5;
}
#falha small { color: #888; font-size: .72rem; max-width: 34em; }
#falha button, #lento button {
font: inherit; font-size: .72rem; letter-spacing: .12em; text-transform: uppercase;
color: #0b0b0c; background: #7fa8c9; border: 0; border-radius: 3px; padding: 10px 16px;
}
#lento { margin-top: 18px; font-family: system-ui, sans-serif; font-size: .7rem; color: #999; }
#lento button { margin-top: 8px; font-size: .64rem; padding: 7px 12px; }
#aviso-leve { margin-top: 10px; font-family: system-ui, sans-serif; font-size: .62rem; color: #777; }
/* ── compact filter panel, top-left — dark chrome, so it reads as part of
the wall's own darkness instead of a lit slab thrown on top of it.
Every colour below comes from these tokens: retheme by editing them. ─── */
#filters {
--f-surface: rgba(13,13,15,.9);
--f-border: rgba(255,255,255,.14);
--f-line: rgba(255,255,255,.09);
--f-text: #eae7e0;
--f-muted: #8e8e87;
--f-faint: #6e6e68;
--f-field: rgba(255,255,255,.055);
--f-field-border: rgba(255,255,255,.16);
--f-accent: #7fa8c9;
--f-accent-soft: rgba(127,168,201,.15);
--f-accent-line: rgba(127,168,201,.34);
--f-gold: #d8b04a;
--f-danger: #d98a72;
/* right side on a desktop: the left corner is the wordmark's, and the
panel above the open card (z 20) so filtering never needs it closed */
position: fixed; top: 14px; right: 14px; z-index: 25;
width: 262px; max-width: calc(100vw - 28px);
font-family: 'Public Sans', system-ui, sans-serif;
background: var(--f-surface); border: 1px solid var(--f-border);
border-radius: 8px; color: var(--f-text);
box-shadow: 0 16px 40px rgba(0,0,0,.55);
backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px);
color-scheme: dark; /* native select popups, caret and scrollbars follow suit */
accent-color: var(--f-accent);
}
#filters-head {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 9px 11px; cursor: pointer; user-select: none; border-radius: 8px;
}
#filters-head:focus-visible { outline: 2px solid var(--f-accent); outline-offset: -2px; }
#filters-head .label {
font-size: .68rem; letter-spacing: .06em; text-transform: uppercase;
color: var(--f-muted); font-weight: 700;
}
#filters-head .right { display: flex; align-items: center; gap: 7px; flex-shrink: 0; }
#filters-head .chevron { font-size: .82rem; line-height: 1; color: var(--f-gold); transition: transform .2s ease; }
#filters.open #filters-head .chevron { transform: rotate(180deg); }
/* live count of matching pieces — the panel stays informative while collapsed */
#filters-count {
font-variant-numeric: tabular-nums; background: var(--f-accent-soft); color: var(--f-accent);
border: 1px solid var(--f-accent-line);
border-radius: 3px; padding: 0 6px; font-size: .62rem; font-weight: 600;
}
#filters-count.none {
background: rgba(217,138,114,.15); color: var(--f-danger);
border-color: rgba(217,138,114,.34);
}
/* it appears the moment the first filter bites — as `hidden` it did so by
growing the header, nudging everything under it down mid-drag. It keeps
its box instead, so the panel's geometry never depends on its state. */
#filters-count.empty { visibility: hidden; }
/* ── active-filter chips: the panel's state while it is *closed*, each one
removable. Open, they are redundant (every filter is visible in its own
control) and actively harmful: a chip appearing on the first drag frame
inserted a whole row above the body and slid the slider out from under
the finger. An open panel is a fixed layout, top to bottom. ─────────── */
#filters-chips { display: flex; flex-wrap: wrap; gap: 5px; padding: 0 11px 9px; }
#filters.open #filters-chips { display: none; }
.chip {
display: inline-flex; align-items: center; gap: 3px; max-width: 100%;
font-size: .62rem; line-height: 1.7; background: var(--f-accent-soft); color: var(--f-accent);
border: 1px solid var(--f-accent-line); border-radius: 999px; padding: 0 3px 0 8px;
}
.chip .txt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip button {
all: unset; cursor: pointer; flex-shrink: 0;
width: 14px; height: 14px; border-radius: 50%;
text-align: center; line-height: 13px; font-size: .72rem; color: var(--f-accent);
}
.chip button:hover { background: rgba(127,168,201,.28); color: #dcebf6; }
#filters-body { display: none; padding: 2px 11px 11px; border-top: 1px solid var(--f-line); }
#filters.open #filters-body { display: block; }
.f-note {
margin-top: 8px; font-size: .6rem; line-height: 1.45; color: var(--f-faint);
}
.f-row { margin-top: 11px; }
.f-row label {
display: flex; align-items: baseline; justify-content: space-between; gap: 6px;
font-size: .6rem; letter-spacing: .08em; text-transform: uppercase;
color: var(--f-muted); margin-bottom: 4px; font-weight: 600;
}
.f-row label .val {
text-transform: none; letter-spacing: 0; font-size: .66rem; font-weight: 600;
color: var(--f-text); font-variant-numeric: tabular-nums;
}
.f-row select, .f-row input[type="text"], .f-row input[type="search"] {
width: 100%; -webkit-appearance: none; appearance: none; font-family: 'Public Sans', system-ui, sans-serif; font-size: .76rem;
background: var(--f-field); color: var(--f-text); border: 1px solid var(--f-field-border);
border-radius: 5px; padding: 5px 7px; outline: none;
}
/* the popup list is painted by the OS — give it a real ground, not the
panel's translucent one, or it inherits the wall behind the field */
.f-row select { text-overflow: ellipsis; } /* long author names on a narrow phone */
.f-row option { background: #15151a; color: var(--f-text); }
.f-row input::placeholder { color: var(--f-faint); }
.f-row input[type="search"]::-webkit-search-decoration,
.f-row input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; }
.f-row select:focus, .f-row input:focus { border-color: var(--f-accent); }
/* text field with an inline clear button */
.f-field { position: relative; }
.f-field input { padding-right: 25px; }
.f-field .f-x {
all: unset; position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
width: 17px; height: 17px; border-radius: 50%; cursor: pointer;
text-align: center; line-height: 16px; font-size: .8rem; color: var(--f-muted);
}
.f-field .f-x:hover { background: rgba(255,255,255,.12); color: var(--f-text); }
/* ── decade histogram: distribution of the *currently* filtered set, so the
slider is aimed at something instead of guessed at. Bars are clickable. ─ */
.f-hist { display: flex; align-items: flex-end; gap: 1px; height: 28px; margin-bottom: 4px; }
.f-hist .bar {
flex: 1 1 0; min-width: 0; min-height: 2px; cursor: pointer;
background: rgba(255,255,255,.16); border-radius: 1px 1px 0 0;
transition: background .15s ease, height .2s ease;
}
.f-hist .bar.in { background: var(--f-accent); }
.f-hist .bar:hover { background: var(--f-gold); }
/* dual-handle range: two overlaid inputs, only the thumbs take pointer events */
.f-range { position: relative; height: 20px; }
.f-range-track {
position: absolute; left: 7px; right: 7px; top: 50%; transform: translateY(-50%);
height: 4px; background: rgba(255,255,255,.13); border-radius: 2px;
}
.f-range-fill { position: absolute; top: 0; bottom: 0; background: var(--f-accent); border-radius: 2px; }
.f-range input[type="range"] {
position: absolute; left: 0; top: 0; width: 100%; height: 20px; margin: 0;
-webkit-appearance: none; appearance: none; background: transparent;
pointer-events: none; outline: none; z-index: 2;
}
/* when the handles meet, the one with room to move has to be the grabbable one */
.f-range input[type="range"].top { z-index: 3; }
.f-range input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; pointer-events: auto; cursor: grab;
width: 14px; height: 14px; border-radius: 50%; box-sizing: border-box;
background: #dfeaf2; border: 2px solid #38536b; box-shadow: 0 1px 4px rgba(0,0,0,.6);
}
.f-range input[type="range"]::-webkit-slider-thumb:active { cursor: grabbing; }
.f-range input[type="range"]::-moz-range-thumb {
pointer-events: auto; cursor: grab;
width: 14px; height: 14px; border-radius: 50%; box-sizing: border-box;
background: #dfeaf2; border: 2px solid #38536b; box-shadow: 0 1px 4px rgba(0,0,0,.6);
}
.f-range input[type="range"]::-moz-range-track { background: transparent; border: 0; }
.f-range input[type="range"]:focus-visible::-webkit-slider-thumb { border-color: var(--f-gold); }
.f-range input[type="range"]:focus-visible::-moz-range-thumb { border-color: var(--f-gold); }
#f-result {
margin-top: 12px; font-size: .66rem; color: var(--f-muted); text-align: center;
font-variant-numeric: tabular-nums;
}
#f-result.none { color: var(--f-danger); }
#f-clear {
all: unset; display: block; margin-top: 7px; cursor: pointer;
font-size: .66rem; letter-spacing: .04em; color: var(--f-danger); text-align: center;
padding: 5px; border-radius: 5px; border: 1px dashed var(--f-field-border);
}
#f-clear:hover { border-color: var(--f-danger); background: rgba(217,138,114,.12); }
#f-clear.disabled { opacity: .35; pointer-events: none; }
/* ── the wordmark: the one fixed thing on the wall. Same serif the title
card opens with, so the page keeps its voice after the cover lifts, and
the gold/cream pairing is the one from the eyebrow and the h1. It is
chrome, not content: it never takes a click away from the wall. ─────── */
#wordmark {
position: fixed; z-index: 26; pointer-events: none; user-select: none;
top: calc(7px + env(safe-area-inset-top)); left: 9px;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.06rem; line-height: 1.2; letter-spacing: .015em; color: #f4f1e8;
/* a sombra sozinha sumia sobre peça clara -- e peça clara é metade do
acervo (papel, álbum, anotação em nanquim). O fundo é o mesmo preto da
dica lá embaixo, e o padding recua a posição no mesmo tanto pra letra
continuar caindo onde caía. */
background: rgba(4,4,5,.92); border-radius: 4px; padding: 8px 10px 9px;
text-shadow: 0 2px 16px rgba(0,0,0,.95), 0 0 4px rgba(0,0,0,.8);
}
#wordmark .a {
display: block; font-family: 'Public Sans', system-ui, sans-serif;
font-size: .56rem; letter-spacing: .26em; text-transform: uppercase;
color: #d8b04a; margin-bottom: 3px;
}
/* the sheet's backdrop: mobile only, and only while the sheet is up */
#filters-scrim {
display: none; position: fixed; inset: 0; z-index: 24;
background: rgba(0,0,0,.45); opacity: 0; pointer-events: none;
transition: opacity .3s ease;
}
#hint {
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 10;
font-family: system-ui, sans-serif; font-size: .68rem; letter-spacing: .06em; color: #999;
background: rgba(4,4,5,.92); border-radius: 4px; padding: 6px 10px; white-space: nowrap;
pointer-events: none; transition: opacity 1s ease;
}
#hint .touch { display: none; }
@media (pointer: coarse) {
#hint .desk { display: none; }
#hint .touch { display: inline; }
}
/* ── metadata card ────────────────────────────────────────────────── */
/* The wall goes soft behind an open piece: the sharp copy is the one in the
card, and everything else steps back out of focus. */
#card-wrap {
position: fixed; inset: 0; z-index: 20;
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none;
background: rgba(6,6,8,0);
backdrop-filter: blur(0px); -webkit-backdrop-filter: blur(0px);
transition: opacity .35s ease, background-color .4s ease,
backdrop-filter .4s ease, -webkit-backdrop-filter .4s ease;
}
#card-wrap.open {
opacity: 1; pointer-events: auto;
background: rgba(6,6,8,.3);
backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px);
}
#card {
position: relative;
width: min(420px, 90vw);
max-height: 86vh;
overflow-y: auto;
background: rgba(6,6,8,.86);
backdrop-filter: blur(6px);
border-radius: 6px;
padding: 34px 30px 26px;
text-align: center;
box-shadow: 0 30px 80px rgba(0,0,0,.6);
transform: translateY(10px) scale(.97);
transition: transform .35s 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.55rem; 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-seealso span { color: #cdd8e0; }
#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);
image-rendering: auto;
}
#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-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 12px; }
#card-tags:empty { display: none; }
#card-tags span {
font-family: system-ui, sans-serif; font-size: .64rem; letter-spacing: .02em; line-height: 1.7;
padding: 0 8px; border-radius: 999px; color: #b8c6d1;
background: rgba(127,168,201,.12); border: 1px solid rgba(127,168,201,.22);
}
#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; }
/* stepping between pieces without a keyboard */
#card-nav { display: flex; gap: 9px; margin-top: 16px; }
#card-nav button {
all: unset; flex: 1 1 0; box-sizing: border-box; cursor: pointer; text-align: center;
font-family: system-ui, sans-serif; font-size: .7rem; letter-spacing: .05em; color: #cdd8e0;
background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.14);
border-radius: 6px; padding: 9px 6px;
}
#card-nav button:hover { background: rgba(255,255,255,.13); }
#card-nav button:active { background: rgba(255,255,255,.2); }
/* ── small screens / fingers ─────────────────────────────────────────────
Two things change shape here: the filter panel spans the width instead
of crowding a corner, and the card becomes a bottom sheet so the piece
you just tapped stays visible on the strip of wall above it. Every hit
target is grown to something a thumb can actually land on. ─────────── */
@media (max-width: 640px) {
#title-screen .stats { gap: 18px; }
#title-screen .enter { margin-top: 34px; }
/* The panel becomes what a phone expects at the bottom of the screen: a
sheet parked on the edge showing only its handle, its title and whatever
filters are active, which rises when you pull it up. It also puts the
controls where the thumb already is, instead of at the top of the screen.
--sheet-peek (measured in JS: the body's offset inside the panel) is how
much of it stays visible while it is down. */
#filters {
left: 0; right: 0; width: auto; max-width: none;
top: auto; bottom: 0;
border-radius: 16px 16px 0 0;
border-left: 0; border-right: 0; border-bottom: 0;
box-shadow: 0 -14px 44px rgba(0,0,0,.6);
padding-bottom: env(safe-area-inset-bottom);
transform: translateY(var(--sheet-hide, 340px));
transition: transform .34s cubic-bezier(.32, .72, 0, 1);
touch-action: none; /* dragging the sheet is never a page scroll */
}
#filters.open { transform: translateY(0); }
#filters::before { /* grab handle — the sheet reads as draggable before it is touched */
content: ''; display: block; width: 40px; height: 4px; margin: 9px auto 1px;
border-radius: 2px; background: rgba(255,255,255,.24);
}
#filters-scrim { display: block; }
#filters-scrim.on { opacity: 1; pointer-events: auto; }
#filters-head { padding: 11px 14px 12px; }
#filters-head .label { font-size: .72rem; }
/* the chevron means "this way": up while the sheet is parked, down once
it is up. Anchored at the bottom, the desktop sense of it is backwards. */
#filters-head .chevron { transform: rotate(180deg); }
#filters.open #filters-head .chevron { transform: rotate(0deg); }
#filters-count { font-size: .66rem; padding: 2px 7px; }
/* the transform does the hiding, so the body is always laid out — that is
what makes the peek measurable and the slide animate */
#filters-body {
display: block; padding-left: 14px; padding-right: 14px;
max-height: min(64dvh, 540px); overflow-y: auto; overscroll-behavior: contain;
-webkit-overflow-scrolling: touch; padding-bottom: 14px;
touch-action: pan-y;
}
.f-row select, .f-row input { font-size: 16px; padding: 8px 9px; } /* 16px: no iOS focus-zoom */
.f-field input { padding-right: 34px; }
.f-field .f-x { width: 26px; height: 26px; line-height: 25px; right: 4px; font-size: 1rem; }
#filters-chips { gap: 6px; padding-bottom: 11px; }
.chip { font-size: .7rem; line-height: 2; padding: 0 4px 0 10px; }
.chip button { width: 19px; height: 19px; line-height: 18px; font-size: .82rem; }
.f-hist { height: 34px; gap: 2px; }
.f-range { height: 32px; }
.f-range input[type="range"] { height: 32px; }
.f-range input[type="range"]::-webkit-slider-thumb { width: 22px; height: 22px; }
.f-range input[type="range"]::-moz-range-thumb { width: 22px; height: 22px; }
.f-range-track { left: 11px; right: 11px; }
#f-result { font-size: .72rem; }
#f-clear { width: 100%; padding: 9px; font-size: .72rem; }
#hint {
left: 8px; right: 8px; transform: none; text-align: center; white-space: normal;
bottom: calc(var(--sheet-peek, 56px) + 12px + env(safe-area-inset-bottom));
font-size: .66rem; line-height: 1.5;
}
#wordmark { font-size: .96rem; left: 6px; }
/* both are sheets on the same edge, and the filter sheet now sits above the
card: park the card on top of its peek instead of under it, or the peek
would cover the card's own ‹ anterior / próxima › */
#card-wrap {
align-items: flex-end;
padding-bottom: calc(var(--sheet-peek, 56px) + env(safe-area-inset-bottom));
}
#card {
/* the sheet is capped short of the screen on purpose: the band of wall it
leaves above is where the opened piece goes, and at 86dvh there was no
band — the piece rose straight behind the sheet */
width: 100%; max-width: none; max-height: min(66dvh, 560px);
overscroll-behavior: contain;
border-radius: 16px 16px 0 0; background: rgba(6,6,8,.94);
padding: 12px 18px calc(16px + env(safe-area-inset-bottom));
transform: translateY(26px) scale(1);
}
#card::before { /* grab handle — says "sheet", and it can be swiped */
content: ''; display: block; width: 40px; height: 4px; margin: 0 auto 12px;
border-radius: 2px; background: rgba(255,255,255,.24);
}
#card-back { top: 8px; left: 8px; width: 34px; height: 34px; line-height: 32px; }
#card-type { font-size: 1.3rem; }
#card-seealso { margin-bottom: 14px; }
#card-img-wrap { max-width: min(210px, 48vw); margin-bottom: 14px; }
#card-nav button { padding: 12px 6px; font-size: .74rem; }
}
@media (max-width: 380px) {
#card { padding-left: 14px; padding-right: 14px; }
#card-img-wrap { max-width: 165px; }
}
/* a phone held sideways: a full-height sheet would swallow the screen */
@media (max-height: 460px) and (max-width: 900px) {
#card-wrap { align-items: center; }
#card {
width: min(460px, 92vw); max-height: 92dvh; border-radius: 8px;
padding: 12px 18px 14px;
}
#card::before { display: none; }
#card-img-wrap { max-width: 150px; margin-bottom: 10px; }
#filters-body { max-height: 58dvh; }
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="loading">
<p id="loading-msg">carregando mural…</p>
<div id="title-screen">
<div class="eyebrow">Biblioteca Nacional · Acervo Digital</div>
<h1>Brasiliana Fotográfica</h1>
<p class="sub">um mural do acervo fotográfico brasileiro</p>
<div class="stats">
<div class="stat"><b id="stat-pecas">—</b><span>peças</span></div>
<div class="stat"><b id="stat-periodo">—</b><span>período</span></div>
<div class="stat"><b id="stat-instituicoes">—</b><span>instituições</span></div>
</div>
<div class="enter" id="enter-label">carregando imagens… 0%</div>
<div id="progress-track"><div id="progress-bar"></div></div>
<div id="aviso-leve" hidden>modo leve — a tentativa anterior não terminou de carregar</div>
</div>
</div>
<div id="wordmark"><span class="a">Acervo</span>Brasiliana</div>
<div id="filters-scrim" aria-hidden="true"></div>
<div id="filters">
<div id="filters-head" role="button" tabindex="0" aria-expanded="false" aria-controls="filters-body">
<span class="label">filtros</span>
<span class="right">
<span id="filters-count" class="empty"></span>
<span class="chevron" aria-hidden="true">▾</span>
</span>
</div>
<div id="filters-chips" hidden></div>
<div id="filters-body">
<p class="f-note" id="filters-label">carregando…</p>
<div class="f-row">
<label for="f-search">Busca livre</label>
<div class="f-field">
<input type="search" id="f-search" autocomplete="off" placeholder="qualquer palavra dos metadados…">
<button type="button" class="f-x" id="f-search-x" aria-label="limpar busca" hidden>×</button>
</div>
</div>
<div class="f-row">
<label for="f-institution">Instituição</label>
<select id="f-institution"><option value="">todas</option></select>
</div>
<div class="f-row">
<label for="f-year-lo"><span>Década</span><span class="val" id="f-year-readout">—</span></label>
<div class="f-hist" id="f-hist"></div>
<div class="f-range">
<div class="f-range-track"><div class="f-range-fill" id="f-year-fill"></div></div>
<input type="range" id="f-year-lo" aria-label="década inicial">
<input type="range" id="f-year-hi" aria-label="década final">
</div>
</div>
<div class="f-row">
<label for="f-author">Autor / fotógrafo</label>
<select id="f-author"><option value="">todos</option></select>
</div>
<div class="f-row">
<label for="f-subject">Assunto</label>
<select id="f-subject"><option value="">todos</option></select>
</div>
<div class="f-row">
<label for="f-type">Tipo de suporte</label>
<select id="f-type"><option value="">todos</option></select>
</div>
<div id="f-result">—</div>
<button id="f-clear" class="disabled">limpar filtros</button>
</div>
</div>
<p id="hint">
<span class="desk">arraste para deslocar · scroll para aproximar · clique numa peça · setas navegam com a ficha aberta · <b style="font-weight:600;color:#bbb">f</b> filtros · <b style="font-weight:600;color:#bbb">/</b> busca</span>
<span class="touch">arraste para deslocar · pinça para aproximar · toque numa peça</span>
</p>
<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-tags"></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 id="card-nav">
<button type="button" id="card-prev" aria-label="peça anterior">‹ anterior</button>
<button type="button" id="card-next" aria-label="próxima peça">próxima ›</button>
</div>
</div>
</div>
<script type="module">
import * as THREE from 'three';
const isMobile = window.matchMedia('(max-width: 640px)').matches;
const isTouch = window.matchMedia('(pointer: coarse)').matches;
// ─── renderer / scene / camera ─────────────────────────────────────────────
const canvas = document.getElementById('canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: !isTouch, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isTouch ? 1.75 : 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
// ─── light mode and the crash-loop guard ──────────────────────────────────
// On an iPhone, running out of memory throws nothing: the system kills the tab
// and reloads it by itself, the page starts the very same load, dies again — a
// loop. A mark in sessionStorage survives that automatic reload, so the next
// attempt knows the last one never finished and goes straight to the light
// atlas instead of repeating the crash.
const LOAD_MARK = 'mural_carregando';
const store = (kind, fn) => { try { return fn(window[kind]); } catch (e) { return null; } };
const previousLoadDied = !!store('sessionStorage', s => s.getItem(LOAD_MARK));
const lightMode = isTouch || previousLoadDied
|| store('localStorage', s => s.getItem('mural_leve')) === '1'
|| renderer.capabilities.maxTextureSize < 8192
|| (navigator.deviceMemory !== undefined && navigator.deviceMemory <= 4);
function showFailure(title, detail) {
if (document.getElementById('falha')) return;
const box = document.createElement('div');
box.id = 'falha';
const h = document.createElement('div'); h.textContent = title;
const small = document.createElement('small');
small.textContent = detail || 'Tentar de novo carrega a versão leve do mural, feita para celular.';
const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = 'tentar de novo';
btn.addEventListener('click', () => {
store('sessionStorage', s => s.setItem(LOAD_MARK, String(Date.now()))); // retry in light mode
location.reload();
});
box.append(h, small, btn);
document.body.appendChild(box);
}
// the GPU dropping the wall's textures is a memory signal too — say so, and let
// the reader decide; preventDefault keeps the browser from giving up silently
canvas.addEventListener('webglcontextlost', e => {
e.preventDefault();
store('sessionStorage', s => s.setItem(LOAD_MARK, String(Date.now())));
showFailure('O navegador liberou a memória do mural.');
}, false);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b0b0c);
const START_Z = isMobile ? 17 : 26;
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 500);
camera.position.set(0, 0, START_Z);
function resize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
new ResizeObserver(resize).observe(document.documentElement);
// iOS collapses/expands its URL bar without resizing the documentElement
window.visualViewport?.addEventListener('resize', resize);
window.addEventListener('orientationchange', () => setTimeout(resize, 250));
// ─── pan / zoom over the flat wall (no orbit — it's a wall, not a globe) ───
// the camera itself never moves on selection — only the tiles do (see selectTile below)
// one pointer pans, two pinch to zoom; the wheel keeps doing the desktop job.
// upper bound generous enough to fit the whole wall (thousands of tiles,
// ~180 units wide) in view at once, well under the camera's far plane (500)
const MIN_Z = 4, MAX_Z = 400;
const camTarget = new THREE.Vector3(0, 0, START_Z); // camera eases toward this each frame
// world units per screen pixel at a given distance — panning then tracks the
// finger exactly at every zoom level instead of racing away when zoomed in
// (at the default distance this works out to the old fixed 0.03 factor)
const HALF_FOV_TAN = Math.tan(THREE.MathUtils.degToRad(50 / 2));
const worldPerPx = (z = camTarget.z) => (2 * z * HALF_FOV_TAN) / window.innerHeight;
const visibleHeight = (z = camTarget.z) => 2 * z * HALF_FOV_TAN;
renderer.domElement.addEventListener('wheel', e => {
e.preventDefault();
if (selected) return; // navigation is frozen while a card is open
// multiplicative, not additive: a notch of the wheel covers the same *share*
// of the distance at every zoom, instead of crawling far out and lurching close
camTarget.z = THREE.MathUtils.clamp(camTarget.z * Math.exp(e.deltaY * 0.0011), MIN_Z, MAX_Z);
}, { passive: false });
const pointers = new Map(); // live pointers on the canvas, by pointerId
let gesture = null; // camera + finger state sampled when the set changed
let multi = false; // this gesture was ever a pinch → the last lift isn't a tap
function centroid() {
let x = 0, y = 0;
for (const p of pointers.values()) { x += p.x; y += p.y; }
return { x: x / pointers.size, y: y / pointers.size };
}
function spread() {
const [a, b] = [...pointers.values()];
return Math.max(1, Math.hypot(a.x - b.x, a.y - b.y));
}
// re-baselined whenever a finger lands or lifts, so the view never jumps
function rebase() {
const c = centroid();
gesture = {
cx: c.x, cy: c.y,
camX: camTarget.x, camY: camTarget.y, camZ: camTarget.z,
spread: pointers.size > 1 ? spread() : 0,
k: worldPerPx(),
};
}
/* ─── the throw ──────────────────────────────────────────────────────────────
A drag that ends while still moving keeps going and slows like something with
weight, instead of stopping dead the instant the finger leaves the glass.
`vel` is the live speed of the drag, smoothed; `glide` is what it was thrown
with, decaying every frame after the release. Both are world units a second,
so a throw covers the same amount of wall at any zoom. */
const glide = new THREE.Vector2();
const vel = new THREE.Vector2();
let lastMoveAt = 0;
const GLIDE_TAU = 0.42; // how long the coast lasts
const GLIDE_MAX = 90; // ceiling on a flick, in world units a second
renderer.domElement.addEventListener('pointerdown', e => {
if (selected) return; // navigation is frozen while a card is open
renderer.domElement.setPointerCapture?.(e.pointerId);
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, ox: e.clientX, oy: e.clientY, moved: false });
if (pointers.size > 1) multi = true;
glide.set(0, 0); vel.set(0, 0); // a finger on the glass stops the wall dead
lastMoveAt = performance.now();
rebase();
});
renderer.domElement.addEventListener('pointermove', e => {
const p = pointers.get(e.pointerId);
if (!p || !gesture) return;
p.x = e.clientX; p.y = e.clientY;
// a finger wobbles far more than a mouse does on a click
if (Math.hypot(p.x - p.ox, p.y - p.oy) > (e.pointerType === 'mouse' ? 4 : 12)) p.moved = true;
if (pointers.size > 1) camTarget.z = THREE.MathUtils.clamp(gesture.camZ * (gesture.spread / spread()), MIN_Z, MAX_Z);
const c = centroid();
const k = pointers.size > 1 ? worldPerPx() : gesture.k; // pinching also drags the midpoint
const px = camTarget.x, py = camTarget.y;
camTarget.x = gesture.camX - (c.x - gesture.cx) * k;
camTarget.y = gesture.camY + (c.y - gesture.cy) * k;
// speed of the drag itself, smoothed — one jittery sample must not decide
// where the wall gets thrown, and a stale one must not survive a pause
const t = performance.now();
const dt = (t - lastMoveAt) / 1000;
lastMoveAt = t;
if (dt > 0 && dt < 0.1) {
vel.x += ((camTarget.x - px) / dt - vel.x) * 0.35;
vel.y += ((camTarget.y - py) / dt - vel.y) * 0.35;
} else vel.set(0, 0);
});
function endPointer(e) {
const p = pointers.get(e.pointerId);
if (!p) return;
pointers.delete(e.pointerId);
if (pointers.size) { rebase(); return; } // other fingers still down
if (!p.moved && !multi && e.type === 'pointerup') handleTap(e.clientX, e.clientY, e.pointerType !== 'mouse');
// a finger that came to rest before lifting is holding the wall, not throwing it
else if (e.type === 'pointerup' && performance.now() - lastMoveAt < 90) {
glide.copy(vel).clampLength(0, GLIDE_MAX);
}
vel.set(0, 0);
multi = false;
gesture = null;
}
window.addEventListener('pointerup', endPointer);
window.addEventListener('pointercancel', endPointer);
// on a phone the open panel covers most of the wall — a tap on the wall means
// "let me see it", so the first tap puts the panel away rather than selecting
function handleTap(x, y, touch) {
if (isMobile && filtersEl.classList.contains('open')) { toggleFilters(false); return; }
doRaycast(x, y, touch);
}
// ─── grid of tiles ──────────────────────────────────────────────────────────
// A mesh per piece meant 12.552 draw calls a frame, every one of them a
// transparent object the renderer had to depth-sort first — which is what made
// zooming out stutter: the further you pull back, the more of them are on
// screen. Instead there is one InstancedMesh per atlas page (four in all), and
// a tile is a plain object holding its own animation state. Draw calls no
// longer depend on the zoom level.
const tileMeshes = []; // Tile[], in grid order — arrow navigation walks this
const pages = []; // one per atlas page: { mesh, tiles, dims, dimAttr, dirty }
const pageMeshes = []; // the raycast targets, kept as a stable array
const animating = new Set(); // tiles still easing toward their target
const tileGroup = new THREE.Group();
scene.add(tileGroup);
const GUTTER = 1.12; // spacing multiplier so tiles don't touch
let GRID_COLS = 1; // set by buildGrid, used by arrow-key navigation to move by row/col
// every tile is the same quad — only its instance matrix, atlas offset and
// dimming differ, so the geometry is built once and shared
const tileGeo = new THREE.PlaneGeometry(1, 1);
const placeholderMat = new THREE.MeshBasicMaterial({ color: 0x1a1a1a });
// MeshBasicMaterial knows nothing about per-instance atlas windows or dimming,
// so both are fed in as instanced attributes and patched into its shader
function tileMaterial(tex, tpr) {
const mat = new THREE.MeshBasicMaterial({ map: tex });
mat.onBeforeCompile = shader => {
shader.uniforms.uUvScale = { value: 1 / tpr };
shader.vertexShader = `
attribute vec2 aUv;
attribute float aDim;
uniform float uUvScale;
varying float vDim;
` + shader.vertexShader.replace(
'#include <uv_vertex>',
'vMapUv = uv * uUvScale + aUv;\n vDim = aDim;'
);
// the dim factor is a perceptual one (0.07 reads as "nearly gone"), but it
// lands in linear space — decode it or a dimmed wall comes out four times
// brighter than the old alpha blend did
shader.fragmentShader = 'varying float vDim;\n' + shader.fragmentShader.replace(
'#include <map_fragment>',
'#include <map_fragment>\n diffuseColor.rgb *= pow( vDim, 2.2 );'
);
};
return mat;
}
function buildGrid(handles, mapEntries, tpr, nPages) {
const n = handles.length;
const cols = Math.max(1, Math.round(Math.sqrt(n * (window.innerWidth / window.innerHeight) * 1.4)));
const rows = Math.ceil(n / cols);
GRID_COLS = cols;
const counts = new Array(nPages).fill(0);
handles.forEach(h => counts[mapEntries[h].atlas || 0]++);
for (let i = 0; i < nPages; i++) {
const count = Math.max(1, counts[i]);
const geo = tileGeo.clone(); // the quad is shared; the per-instance attributes are not
const uvAttr = new THREE.InstancedBufferAttribute(new Float32Array(count * 2), 2);
const dims = new Float32Array(count).fill(1);
const dimAttr = new THREE.InstancedBufferAttribute(dims, 1);
dimAttr.setUsage(THREE.DynamicDrawUsage);
geo.setAttribute('aUv', uvAttr);
geo.setAttribute('aDim', dimAttr);
const mesh = new THREE.InstancedMesh(geo, placeholderMat, count);
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.frustumCulled = false; // the wall's bounds shift as tiles animate, and four objects cost nothing
mesh.userData.page = i;
tileGroup.add(mesh);
pages.push({ mesh, tiles: [], dims, dimAttr, dirty: false });
pageMeshes.push(mesh);
}
const m4 = new THREE.Matrix4();
handles.forEach((handle, i) => {
const col = i % cols, row = Math.floor(i / cols);
const entry = mapEntries[handle];
const page = pages[entry.atlas || 0];
const iid = page.tiles.length;
const pos = new THREE.Vector3(
(col - cols / 2) * GUTTER,
(rows / 2 - row) * GUTTER,
(Math.random() - 0.5) * 0.6 // slight depth jitter, like the reference photo
);
const tile = {
page: entry.atlas || 0, iid,
pos: pos.clone(), scl: 1, dim: 1,
userData: {
handle, meta: entry.meta,
basePos: pos.clone(),
targetPos: pos.clone(),
targetScale: 1,
targetOpacity: 1, // dimmed (not hidden) by the filter panel — keeps the grid layout intact
},
};
// the tile's window into the atlas, as a uv offset off the shared quad
page.mesh.geometry.attributes.aUv.setXY(iid, entry.col / tpr, 1 - (entry.row + 1) / tpr);
m4.makeScale(1, 1, 1).setPosition(pos);
page.mesh.setMatrixAt(iid, m4);
page.tiles.push(tile);
tileMeshes.push(tile);
tileByHandle.set(handle, tile);
});
pages.forEach(p => { p.mesh.instanceMatrix.needsUpdate = true; p.mesh.geometry.attributes.aUv.needsUpdate = true; });
return { cols, rows };
}
// a tile only costs anything on the frames where it is actually moving
function markAnimating(tile) { animating.add(tile); }
const _m4 = new THREE.Matrix4();
// the tiles ease on the same clock the camera does: 0.16 a frame *was* the
// right weight at 60 Hz, and twice that on a 120 Hz phone
const TAU_TILE = 0.096;
function updateTileAnimation(dt) {
if (!animating.size) return;
const k = ease(dt, TAU_TILE);
for (const t of animating) {
const d = t.userData;
let settled = true;
if (t.pos.distanceToSquared(d.targetPos) > 1e-6) { t.pos.lerp(d.targetPos, k); settled = false; }
else t.pos.copy(d.targetPos);
if (Math.abs(t.scl - d.targetScale) > 1e-4) { t.scl += (d.targetScale - t.scl) * k; settled = false; }
else t.scl = d.targetScale;
if (Math.abs(t.dim - d.targetOpacity) > 1e-4) { t.dim += (d.targetOpacity - t.dim) * k; settled = false; }
else t.dim = d.targetOpacity;
const page = pages[t.page];
_m4.makeScale(t.scl, t.scl, 1).setPosition(t.pos);
page.mesh.setMatrixAt(t.iid, _m4);
page.dims[t.iid] = t.dim;
page.dirty = true;
if (settled) animating.delete(t);
}
for (const page of pages) {
if (!page.dirty) continue;
page.mesh.instanceMatrix.needsUpdate = true;
page.dimAttr.needsUpdate = true;
page.dirty = false;
}
}
// ─── raycasting ─────────────────────────────────────────────────────────────
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let hovered = null;
// dimmed-out tiles are inert: they can't be hovered, clicked or stepped onto,
// so an active filter behaves like a real subset and not just a paint job
function firstLiveHit() {
const hits = raycaster.intersectObjects(pageMeshes, false);
for (const h of hits) {
const tile = pages[h.object.userData.page].tiles[h.instanceId];
if (tile && !tile.userData.filteredOut) return tile;
}
return null;
}
// the canvas' own box, not the window: on iOS the two drift apart whenever the
// URL bar slides and the picked point lands a tile or two off the finger
function pickAt(cx, cy) {
const r = renderer.domElement.getBoundingClientRect();
pointer.x = ((cx - r.left) / r.width) * 2 - 1;
pointer.y = -((cy - r.top) / r.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
return firstLiveHit();
}
// A fingertip covers far more wall than a tile does at the opening zoom (a tile
// is ~50 px there), so a tap that looks dead-on often lands in a gutter. On
// touch, sweep a small ring around the point before calling it a miss.
const TAP_RING = [[0, 0], [0, -11], [11, 0], [0, 11], [-11, 0], [-8, -8], [8, -8], [8, 8], [-8, 8]];
function doRaycast(cx, cy, touch) {
for (const [ox, oy] of (touch ? TAP_RING : [[0, 0]])) {
const m = pickAt(cx + ox, cy + oy);
if (m) { openTile(m); return; }
}
}
renderer.domElement.addEventListener('pointermove', e => {
if (e.pointerType !== 'mouse' || pointers.size || selected) return; // no hover state on a finger
const m = pickAt(e.clientX, e.clientY);
if (m !== hovered) {
if (hovered) { hovered.userData.targetScale = 1; markAnimating(hovered); }
hovered = m;
if (hovered) { hovered.userData.targetScale = 1.08; markAnimating(hovered); }
renderer.domElement.style.cursor = m ? 'pointer' : '';
}
});
// ─── selection / card ─────────────────────────────────────────────────────
// the camera never moves here — the *tiles* do: the clicked one grows and
// moves to screen-center, the rest part radially away from that same point
// (the "hole" always opens in the middle, wherever you clicked).
let atlasSources = []; // { source, size } per atlas page — used to crop the tile into the card canvas
let atlasEntries = null, TPR = 1;
let selected = null;
let selectedAt = 0;
// Where the opened piece goes and how big it gets. On a desktop that is simply
// the middle of the screen; on a phone the card is a bottom sheet, so the piece
// is centred in whatever band of wall is left above it — measured off the sheet
// that was just filled in, because its height depends on how much metadata the
// item carries. Guessed at a fixed fraction, the piece ended up behind the sheet.
function framing(visH) {
if (!isMobile) return { lift: 0, scale: 3.4 };
const vh = window.innerHeight;
// Where the wall begins depends on where the filter panel is anchored: as a
// bar at the top it is the line under its header (open or closed — a tap on
// the wall closes the rest of it); as a sheet on the bottom edge it takes
// nothing off the top at all, and it sits behind the card either way.
const fr = filtersEl.getBoundingClientRect();
const top = (fr.bottom < vh - 1 ? fr.top + filtersHead.offsetHeight : 0) + 10;
// the card is parked above the filter sheet's peek, so the wall ends where
// the card's own laid-out top is — not at the bottom of the screen
const pad = parseFloat(getComputedStyle(cardEl.parentElement).paddingBottom) || 0;
const bottom = vh - cardEl.offsetHeight - pad - 10;
const band = bottom - top;
if (band < 70) return { lift: 0, scale: Math.min(3.4, visH * 0.18) }; // no room to speak of
return {
lift: (vh / 2 - (top + bottom) / 2) / vh * visH,
scale: THREE.MathUtils.clamp((band / vh) * visH * 0.82, 1.2, 3.4),
};
}
function selectTile(mesh) {
closing = false;
selected = mesh;
selectedAt = performance.now();
hovered = null;
showCard(mesh.userData); // fill the card first: its height decides the framing
const centerZ = mesh.userData.basePos.z;
const cx = camTarget.x, cy = camTarget.y;
const visH = visibleHeight();
const { lift, scale } = framing(visH);
// Beyond the edge of the screen the parting is invisible, and moving every
// one of twelve thousand instances for it costs a phone the whole animation.
const reach = Math.hypot(visH * camera.aspect, visH) * 0.5 + 8;
tileMeshes.forEach(m => {
if (m === mesh) return;
const dx = m.userData.basePos.x - cx;
const dy = m.userData.basePos.y - cy;
const dist = Math.hypot(dx, dy) || 0.001;
if (dist > reach) { rest(m); return; }
const ux = dx / dist, uy = dy / dist;
const push = THREE.MathUtils.clamp(6.5 - dist * 0.5, 1.5, 6.5); // closer tiles get shoved further
aim(m, m.userData.basePos.clone().add(new THREE.Vector3(ux * push, uy * push, -1.5)), 1);
});
aim(mesh, new THREE.Vector3(cx, cy + lift, centerZ + 4), scale);
}
// a tile only joins the animation set when it has somewhere to go
function aim(m, pos, scale) {
m.userData.targetPos = pos;
m.userData.targetScale = scale;
if (m.pos.distanceToSquared(pos) > 1e-6 || Math.abs(m.scl - scale) > 1e-4) markAnimating(m);
}
function rest(m) { aim(m, m.userData.basePos, 1); }
function deselect() {
closing = false;
tileMeshes.forEach(rest);
document.getElementById('card-wrap').classList.remove('open');
selected = null;
}
document.getElementById('card-back').addEventListener('click', () => closeCard());
// A tap is pointerup *then* a compatibility click. The card opens on the
// pointerup, so that click lands on whatever the card just put under the
// finger — the backdrop (closing it again) or a button on the sheet. Swallow
// anything that arrives within a moment of the card opening.
// stopImmediatePropagation, not stopPropagation: when the click lands on the
// backdrop itself the closing listener below is on that same element, and
// plain stopPropagation does not reach listeners on the target node — so every
// tap that opened a card in the upper half of a phone screen closed it again.
document.getElementById('card-wrap').addEventListener('click', e => {
if (performance.now() - selectedAt < 400) { e.stopImmediatePropagation(); e.preventDefault(); }
}, true);
document.getElementById('card-wrap').addEventListener('click', e => {
if (e.target.id === 'card-wrap') closeCard();
});
// the wall is frozen while a piece is up, so a scroll out there means "give it
// back" — the card folds away and the wheel that followed does the zooming
document.getElementById('card-wrap').addEventListener('wheel', e => {
if (selected && !e.target.closest('#card')) closeCard(); // the card's own body still scrolls
}, { passive: true });
/* ─── browser history ────────────────────────────────────────────────────────
Opening a piece is an event worth going back from: each one becomes a history
entry carrying its handle, so the phone's back gesture (and the browser's own
back button) closes the card instead of leaving the wall, and back/forward
retrace the pieces you opened — including the ones you stepped to with
‹ anterior / próxima ›. The handle also lands in the URL, so a piece can be
linked to and re-opened straight from the address bar.
Everything that changes the view goes through openTile/closeCard; selectTile
and deselect are the raw moves, used as-is when *history* is the one asking. */
const tileByHandle = new Map(); // filled in buildGrid
let historyReady = false; // no entries before the wall can answer for them
function openTile(tile) {
selectTile(tile);
if (!historyReady) return;
const h = tile.userData.handle;
// holding an arrow key walks the wall faster than Safari allows entries to be
// pushed (100 per 30s); the wall keeps working, it just stops recording
try { history.pushState({ h }, '', `#${encodeURIComponent(h)}`); } catch {}
}
let closing = false; // a history.back() already asked for; popstate clears it
function closeCard() {
if (closing) return; // dragging a filter fires apply() per frame — one back, not twelve
// stepping back is what put the card on screen — unwind it the same way, so
// forward can bring the piece back instead of a dead entry
if (historyReady && history.state && history.state.h) { closing = true; history.back(); return; }
deselect();
}
const handleFromHash = () => {
try { return decodeURIComponent(location.hash.slice(1)); } catch { return ''; }
};
window.addEventListener('popstate', e => {
if (!historyReady) return;
const h = (e.state && e.state.h) || handleFromHash();
const tile = h ? tileByHandle.get(h) : null;
if (tile) { if (tile !== selected) selectTile(tile); }
else if (selected) deselect();
});
const filtersEl = document.getElementById('filters');
const filtersHead = document.getElementById('filters-head');
const filtersScrim = document.getElementById('filters-scrim');
const filtersBodyEl = document.getElementById('filters-body');
function toggleFilters(force) {
const open = force !== undefined ? force : !filtersEl.classList.contains('open');
filtersEl.classList.toggle('open', open);
filtersHead.setAttribute('aria-expanded', String(open));
filtersScrim.classList.toggle('on', open && isMobile);
}
filtersScrim.addEventListener('click', () => toggleFilters(false));
// ── the mobile sheet ──────────────────────────────────────────────────────
// Parked on the bottom edge it shows only its handle, title and active chips;
// everything below that is translated off-screen. Both distances are measured
// rather than guessed, because the chips change the panel's height as filters
// come and go.
let sheetHide = 340; // px of panel hanging below the screen while it is down
function measureSheet() {
const peek = filtersBodyEl.offsetTop; // handle + head + chips
sheetHide = Math.max(0, filtersEl.offsetHeight - peek);
const root = document.documentElement.style;
root.setProperty('--sheet-peek', `${peek}px`); // the hint clears this
root.setProperty('--sheet-hide', `${sheetHide}px`);
}
if (isMobile) {
measureSheet();
// the chips row appears and disappears with the filters, changing the peek
new ResizeObserver(measureSheet).observe(filtersEl);
window.addEventListener('orientationchange', () => setTimeout(measureSheet, 260));
}
// drag the handle to raise or drop it, following the finger the whole way
let sheetDrag = null;
filtersEl.addEventListener('touchstart', e => {
if (!isMobile || e.touches.length !== 1) return;
if (e.target.closest('#filters-body')) return; // that area scrolls and holds controls
sheetDrag = {
y: e.touches[0].clientY, moved: 0,
base: filtersEl.classList.contains('open') ? 0 : sheetHide,
};
filtersEl.style.transition = 'none';
}, { passive: true });
window.addEventListener('touchmove', e => {
if (!sheetDrag || !e.touches.length) return;
sheetDrag.moved = e.touches[0].clientY - sheetDrag.y;
const y = Math.min(sheetHide, Math.max(0, sheetDrag.base + sheetDrag.moved));
filtersEl.style.transform = `translateY(${y}px)`;
}, { passive: true });
function endSheetDrag() {
if (!sheetDrag) return;
const { moved } = sheetDrag;
sheetDrag = null;
filtersEl.style.transition = '';
filtersEl.style.transform = ''; // back to whatever the open/closed class says
if (Math.abs(moved) > 40) toggleFilters(moved < 0); // up opens, down closes
}
window.addEventListener('touchend', endSheetDrag);
window.addEventListener('touchcancel', endSheetDrag);
window.addEventListener('keydown', e => {
const typing = /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
if (e.key === 'Escape') {
if (selected) closeCard();
else if (typing) e.target.blur();
else toggleFilters(false);
return;
}
if (e.key === 'f' && !typing) { toggleFilters(); return; }
if (e.key === '/' && !typing) { // straight into the search box
e.preventDefault();
toggleFilters(true);
document.getElementById('f-search').focus();
return;
}
if (!selected || typing) return; // arrow navigation only makes sense with a card open
// move by actual row/col in the grid, not raw array index — Up/Down jump
// a full row (GRID_COLS), Left/Right move one column, both wrap at the ends.
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: GRID_COLS, ArrowUp: -GRID_COLS }[e.key];
if (!step) return;
e.preventDefault();
stepSelection(step);
});
// filtered-out tiles are skipped over, so stepping stays inside the subset
function stepSelection(step) {
if (!selected) return;
const n = tileMeshes.length;
let i = tileMeshes.indexOf(selected);
for (let k = 0; k < n; k++) {
i = (i + step + n) % n;
if (!tileMeshes[i].userData.filteredOut) { openTile(tileMeshes[i]); return; }
}
}
// the same step, reachable without a keyboard
document.getElementById('card-prev').addEventListener('click', () => stepSelection(-1));
document.getElementById('card-next').addEventListener('click', () => stepSelection(1));
// ...and by swiping the card sideways. Vertical stays free for scrolling the
// card's own body, and a downward flick on the sheet closes it.
const cardEl = document.getElementById('card');
let swipe = null;
cardEl.addEventListener('touchstart', e => {
swipe = e.touches.length === 1 ? { x: e.touches[0].clientX, y: e.touches[0].clientY, top: cardEl.scrollTop } : null;
}, { passive: true });
cardEl.addEventListener('touchend', e => {
if (!swipe || !e.changedTouches.length) return;
const dx = e.changedTouches[0].clientX - swipe.x;
const dy = e.changedTouches[0].clientY - swipe.y;
swipe = null;
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.6) stepSelection(dx < 0 ? 1 : -1);
else if (dy > 90 && Math.abs(dy) > Math.abs(dx) * 1.6 && cardEl.scrollTop <= 0) closeCard();
}, { passive: true });
// Every item's page is the same address with its handle in it, so the map used
// to spend 1,2 MB repeating that prefix 12.552 times — and meta.handle another
// 0,4 MB repeating the key it was already filed under. Both are derived here.
const sourceUrl = h => `https://brasilianafotografica.bn.gov.br/brasiliana/handle/${h}?show=full`;
function showCard(data) {
const meta = data.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 ? meta.contributor : '';
// this piece's dc.subject, one chip each (accent/case duplicates folded)
const tagsEl = document.getElementById('card-tags');
tagsEl.replaceChildren();
const seen = new Set();
[].concat(meta.subject || []).forEach(t => {
const k = deburr(String(t)).trim();
if (!k || seen.has(k)) return;
seen.add(k);
const chip = document.createElement('span');
chip.textContent = t;
tagsEl.appendChild(chip);
});
document.getElementById('card-handle').textContent = data.handle || '';
document.getElementById('card-link').href = data.handle ? sourceUrl(data.handle) : '#';
document.getElementById('card-rights').textContent = meta.rights ? `Direitos: ${meta.rights}` : '';
// crop this tile out of the atlas image for a (still small, but framed) preview
const entry = atlasEntries[data.handle];
const img = document.getElementById('card-img');
const page = atlasSources[entry?.atlas || 0];
if (page && entry) {
const S = page.size / TPR;
const out = Math.min(512, Math.round(S * 4)); // no point upscaling past 4x
const c = document.createElement('canvas');
c.width = c.height = out;
const ctx = c.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.drawImage(page.source, entry.col * S, entry.row * S, S, S, 0, 0, out, out);
img.src = c.toDataURL('image/jpeg', 0.92);
}
document.getElementById('card-wrap').classList.add('open');
}
// ─── render loop ────────────────────────────────────────────────────────────
// Easing is a time constant here, never a per-frame fraction: a fixed fraction
// converges twice as fast at 120 Hz as at 60, so the wall had a different weight
// on every screen. `tau` is roughly how long it takes to cover most of the gap.
const ease = (dt, tau) => 1 - Math.exp(-dt / tau);
const TAU_GRAB = 0.035; // finger down — the wall stays under the finger
const TAU_FREE = 0.10; // let go — it settles
const TAU_ZOOM = 0.12;
let lastFrame = performance.now();
function tick(now = lastFrame) {
requestAnimationFrame(tick);
// a backgrounded tab comes back with a gap of seconds: clamp it, or the wall
// teleports on the first frame after the user returns
const dt = Math.min(0.05, Math.max(0, (now - lastFrame) / 1000));
lastFrame = now;
// the coast, before the camera reads its target
if (!pointers.size && !selected && glide.lengthSq() > 0.04) {
camTarget.x += glide.x * dt;
camTarget.y += glide.y * dt;
glide.multiplyScalar(Math.exp(-dt / GLIDE_TAU));
} else if (!pointers.size) glide.set(0, 0);
// pure user pan/zoom — never touched by selection
const kxy = ease(dt, pointers.size ? TAU_GRAB : TAU_FREE);
camera.position.x += (camTarget.x - camera.position.x) * kxy;
camera.position.y += (camTarget.y - camera.position.y) * kxy;
camera.position.z += (camTarget.z - camera.position.z) * ease(dt, TAU_ZOOM);
// no lookAt: the camera stays square to the wall. Aiming it at the *target*
// while it trailed behind tilted the whole view a little on every drag —
// which is what made a fast pan swim instead of slide.
updateTileAnimation(dt);
renderer.render(scene, camera);
}
// ─── filter panel: instituição / década / autor / tipo ─────────────────────
// tiles that don't match are dimmed (targetOpacity), never removed — the
// grid layout stays intact so navigation and the atlas UVs never shift.
filtersHead.addEventListener('click', () => toggleFilters());
filtersHead.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleFilters(); }
});
// pt-BR catalogue text is full of accents and nobody types them into a filter
const deburr = str => str.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
// everything a record carries, flattened into one string — naming the fields
// would quietly drop whatever the catalogue gains later
function flattenMeta(value) {
if (value == null) return '';
if (Array.isArray(value)) return value.map(flattenMeta).join(' ');
if (typeof value === 'object') return Object.values(value).map(flattenMeta).join(' ');
return String(value);
}
function yearOf(meta) {
const m = String((meta || {}).date || '').match(/(1[5-9]\d{2}|20\d{2})/);
return m ? parseInt(m[1], 10) : null;
}
// ─── filter-value cleanup: the catalogue's type / author strings are messy ───
// (OCR debris, case variants, life dates glued on, "Nome Sobrenome" vs "Sobrenome, Nome").
// Only the filter lists and matching use these; the card still shows the raw record.
const KNOWN_TYPES = ['Fotografia/ Papel', 'Negativo/ Vidro', 'Diapositivo/ Vidro', 'Reprodução Fotomecânica/ Papel', 'Negativo flexível'];
function cleanType(raw) {
const s = String(raw || '').replace(/\s+/g, ' ').trim();
if (!s) return '';
const fold = deburr(s);
const known = KNOWN_TYPES.find(k => deburr(k) === fold);
if (known) return known;
// OCR debris: rebuild from the tail that survived ("…afia/ Papel", "…gativo/ Vidro")
if (/(?:rafia|FOa)\/ Papel$/.test(s)) return 'Fotografia/ Papel';
if (/gativo\/ Vidro$/.test(s)) return 'Negativo/ Vidro';
if (/Fotomec/.test(s)) return 'Reprodução Fotomecânica/ Papel';
if (/[<>{}\d]/.test(s)) return ''; // unrecoverable garbage — leave it out of the list
return /[A-ZÀ-Ý]{3,}/.test(s) ? s.charAt(0) + s.slice(1).toLowerCase() : s; // "NEGATIVO flexível" → "Negativo flexível"
}
const NO_AUTHOR = /^(s\/autor|s\/nome|s\/a|\[?s\.?n\.?\]?|sem autor|\[sem autor\]|autoria n[aã]o identificada|autor n[aã]o identificado|n[aã]o identificado|an[oô]nimo)$/i;
function cleanAuthor(raw) {
let s = String(raw || '').replace(/\s+/g, ' ').trim();
s = s.replace(/^Fot[óo]grafo:\s*/i, '')
.replace(/\s*[-–]?\s*\((?:autoria|fot[óo]grafo|firma)\)/gi, '')
.replace(/\s+-\s+(?:Fot[óo]grafo|Est[úu]dio\/Ag[eê]ncia)$/i, '');
// life dates / floruit glued to the end: ", 1864-1957", "(1893 - 1961)", "[? -1908]", "18- -19-"
const tail = /[,\s]*[\[(]?\s*(?:(?:ca\.|fl\.|m\.|n\.)\s*)?(?:\?|\d{2,4}-?)\s*(?:[-–]\s*(?:ca\.\s*)?\d{0,4})?\s*[\])]?[.,\s]*$/i;
for (let i = 0; i < 3 && tail.test(s) && s.replace(tail, '').length > 2; i++) s = s.replace(tail, '');
s = s.replace(/[.,\s]+$/, '').replace(/^\[(.*)\]$/, '$1').trim();
if (!s || NO_AUTHOR.test(s) || /[<>]|\d{3}/.test(s)) return '';
return s;
}
// same person, different word order or accents → same key
const authorKey = name => deburr(name).replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter(Boolean).sort().join(' ');
// dc.subject folds accents and case; the select shows only tags that recur, since
// ~8000 distinct values (57% used once) would be an unusable list
const subjectKey = t => deburr(String(t)).replace(/\s+/g, ' ').trim();
const SUBJECT_MIN = 20;
function setupFilters(mapData) {
const entries = Object.values(mapData.map);
const instCounts = new Map(), typeCounts = new Map(), authorGroups = new Map(), subjectGroups = new Map();
let minYear = Infinity, maxYear = -Infinity;
entries.forEach(e => {
const meta = e.meta || {};
if (meta.community) instCounts.set(meta.community, (instCounts.get(meta.community) || 0) + 1);
const t = cleanType(meta.type);
if (t) typeCounts.set(t, (typeCounts.get(t) || 0) + 1);
new Set([].concat(meta.subject || []).map(subjectKey)).forEach(k => {
if (!k) return;
subjectGroups.set(k, (subjectGroups.get(k) || 0) + 1);
});
const a = cleanAuthor(meta.contributor);
if (a) {
const k = authorKey(a), g = authorGroups.get(k) || { n: 0, forms: new Map() };
g.n++; g.forms.set(a, (g.forms.get(a) || 0) + 1);
authorGroups.set(k, g);
}
const y = yearOf(meta);
if (y != null) { if (y < minYear) minYear = y; if (y > maxYear) maxYear = y; }
});
if (minYear > maxYear) { minYear = 1860; maxYear = 1930; } // no dated items — shouldn't happen, but stay safe
const selInst = document.getElementById('f-institution');
[...instCounts.entries()].sort((a, b) => b[1] - a[1]).forEach(([name, n]) => {
const opt = document.createElement('option');
opt.value = name; opt.textContent = `${name} (${n.toLocaleString('pt-BR')})`;
selInst.appendChild(opt);
});
const selType = document.getElementById('f-type');
[...typeCounts.entries()].sort((a, b) => b[1] - a[1]).forEach(([name, n]) => {
const opt = document.createElement('option');
opt.value = name; opt.textContent = `${name.replace('/', ' / ')} (${n.toLocaleString('pt-BR')})`;
selType.appendChild(opt);
});
// one entry per person: the most common spelling names the group, and
// "Sobrenome, Nome" wins a tie over "Nome Sobrenome"
const authorNames = new Map();
const selAuthor = document.getElementById('f-author');
[...authorGroups.entries()].sort((a, b) => b[1].n - a[1].n).forEach(([key, g]) => {
const name = [...g.forms.entries()].sort((a, b) => b[1] - a[1] || b[0].includes(',') - a[0].includes(','))[0][0];
authorNames.set(key, name);
const opt = document.createElement('option');
opt.value = key; opt.textContent = `${name} (${g.n.toLocaleString('pt-BR')})`;
selAuthor.appendChild(opt);
});
const subjectNames = new Map(); // key → a readable spelling
entries.forEach(e => [].concat((e.meta || {}).subject || []).forEach(t => {
const k = subjectKey(t);
if (k && subjectGroups.get(k) >= SUBJECT_MIN && !subjectNames.has(k)) subjectNames.set(k, String(t).trim());
}));
const selSubject = document.getElementById('f-subject');
[...subjectGroups.entries()].filter(([, n]) => n >= SUBJECT_MIN).sort((a, b) => b[1] - a[1]).forEach(([k, n]) => {
const opt = document.createElement('option');
opt.value = k; opt.textContent = `${subjectNames.get(k)} (${n.toLocaleString('pt-BR')})`;
selSubject.appendChild(opt);
});
const decadeMin = Math.floor(minYear / 10) * 10;
const decadeMax = Math.floor(maxYear / 10) * 10;
const nDecades = (decadeMax - decadeMin) / 10 + 1;
const span = Math.max(1, decadeMax - decadeMin); // guards the single-decade case
const loSlider = document.getElementById('f-year-lo');
const hiSlider = document.getElementById('f-year-hi');
loSlider.min = hiSlider.min = decadeMin;
loSlider.max = hiSlider.max = decadeMax;
loSlider.step = hiSlider.step = 10;
loSlider.value = decadeMin;
hiSlider.value = decadeMax;
const yearReadout = document.getElementById('f-year-readout');
const yearFill = document.getElementById('f-year-fill');
const histEl = document.getElementById('f-hist');
const searchInput = document.getElementById('f-search');
const searchX = document.getElementById('f-search-x');
const clearBtn = document.getElementById('f-clear');
const countBadge = document.getElementById('filters-count');
const chipsEl = document.getElementById('filters-chips');
const resultEl = document.getElementById('f-result');
// parse each tile's year and fold its text once — apply() runs on every
// keystroke and drag frame, so it must stay free of string work
tileMeshes.forEach(m => {
const meta = m.userData.meta || {};
m.userData._year = yearOf(meta);
const a = cleanAuthor(meta.contributor);
m.userData._author = a ? authorKey(a) : '';
m.userData._type = cleanType(meta.type);
m.userData._subjects = new Set([].concat(meta.subject || []).map(subjectKey));
// one haystack per piece: every metadata field, not a chosen few
// the handle is the map's key, not a field of meta — it still has to be
// searchable (people paste a handle or its number straight into the box)
m.userData._hay = deburr(m.userData.handle + ' ' + flattenMeta(meta));
});
const bars = [];
for (let i = 0; i < nDecades; i++) {
const bar = document.createElement('div');
bar.className = 'bar';
const dec = decadeMin + i * 10;
// click a bar to isolate that decade; click it again to release the range
bar.addEventListener('click', () => {
const alone = state.yearTouched && state.yearLo === dec && state.yearHi === dec;
if (alone) { resetYear(); } else {
loSlider.value = hiSlider.value = dec;
state.yearLo = state.yearHi = dec; state.yearTouched = true;
}
apply();
});
histEl.appendChild(bar);
bars.push(bar);
}
const state = { search: '', institution: '', yearLo: decadeMin, yearHi: decadeMax, yearTouched: false, author: '', type: '', subject: '' };
function resetYear() {
state.yearTouched = false;
state.yearLo = decadeMin; state.yearHi = decadeMax;
loSlider.value = decadeMin; hiSlider.value = decadeMax;
}
function apply() {
// free text is AND across terms, so "rio 1900" narrows instead of widening
const terms = deburr(state.search.trim()).split(/\s+/).filter(Boolean);
const decCounts = new Array(nDecades).fill(0);
let shown = 0;
tileMeshes.forEach(m => {
const d = m.userData, meta = d.meta || {};
// everything except the decade — this is what the histogram counts, so the
// bars show what each decade *would* give under the other filters
const okRest =
(!state.institution || meta.community === state.institution) &&
(!state.type || d._type === state.type) &&
(!state.author || d._author === state.author) &&
(!state.subject || d._subjects.has(state.subject)) &&
terms.every(t => d._hay.includes(t));
if (okRest && d._year != null) {
const i = Math.floor((d._year - decadeMin) / 10);
if (i >= 0 && i < nDecades) decCounts[i]++;
}
const okYear = !state.yearTouched ||
(d._year != null && d._year >= state.yearLo && d._year <= state.yearHi + 9);
const ok = okRest && okYear;
d.filteredOut = !ok;
if (d.targetOpacity !== (ok ? 1 : 0.07)) { d.targetOpacity = ok ? 1 : 0.07; markAnimating(m); }
if (ok) shown++;
});
const peak = Math.max(1, ...decCounts);
bars.forEach((bar, i) => {
const dec = decadeMin + i * 10;
// sqrt scale — a linear one flattens every decade next to the 1900s peak
bar.style.height = `${2 + 26 * Math.sqrt(decCounts[i] / peak)}px`;
bar.classList.toggle('in', !state.yearTouched || (dec >= state.yearLo && dec <= state.yearHi));
bar.title = `${dec}s · ${decCounts[i].toLocaleString('pt-BR')} peças`;
});
const lo = state.yearTouched ? state.yearLo : decadeMin;
const hi = state.yearTouched ? state.yearHi : decadeMax;
yearFill.style.left = `${((lo - decadeMin) / span) * 100}%`;
yearFill.style.right = `${((decadeMax - hi) / span) * 100}%`;
yearReadout.textContent = yearLabel(lo, hi);
loSlider.classList.toggle('top', lo > (decadeMin + decadeMax) / 2);
const active = activeFilters();
renderChips(active);
searchX.hidden = !state.search;
clearBtn.classList.toggle('disabled', active.length === 0);
countBadge.classList.toggle('empty', active.length === 0);
countBadge.textContent = shown.toLocaleString('pt-BR');
countBadge.classList.toggle('none', shown === 0);
resultEl.classList.toggle('none', shown === 0);
resultEl.textContent = shown === 0
? 'nenhuma peça corresponde'
: active.length === 0
? `${shown.toLocaleString('pt-BR')} peças com imagem`
: `${shown.toLocaleString('pt-BR')} de ${tileMeshes.length.toLocaleString('pt-BR')} peças`;
// the subset just changed under the open piece — fold the card away so the
// new wall is the thing being looked at (and so a piece that no longer
// matches is not left sitting on top of a wall that excludes it)
if (selected) closeCard();
}
// the last decade is usually a stub (the wall ends in 2022, not 2029), so the
// range label stops at the newest year actually in the acervo
function yearLabel(lo, hi) { return `${lo}–${Math.min(hi + 9, maxYear)}`; }
function activeFilters() {
const out = [];
if (state.search.trim()) out.push({ key: 'search', text: `“${state.search.trim()}”` });
if (state.institution) out.push({ key: 'institution', text: state.institution });
if (state.yearTouched) out.push({ key: 'year', text: yearLabel(state.yearLo, state.yearHi) });
if (state.author) out.push({ key: 'author', text: authorNames.get(state.author) });
if (state.subject) out.push({ key: 'subject', text: subjectNames.get(state.subject) });
if (state.type) out.push({ key: 'type', text: state.type });
return out;
}
function clearOne(key) {
if (key === 'search') { state.search = ''; searchInput.value = ''; }
if (key === 'institution') { state.institution = ''; selInst.value = ''; }
if (key === 'type') { state.type = ''; selType.value = ''; }
if (key === 'subject') { state.subject = ''; selSubject.value = ''; }
if (key === 'author') { state.author = ''; selAuthor.value = ''; }
if (key === 'year') resetYear();
apply();
}
function renderChips(active) {
chipsEl.hidden = active.length === 0;
chipsEl.textContent = '';
active.forEach(({ key, text }) => {
const chip = document.createElement('span');
chip.className = 'chip';
const label = document.createElement('span');
label.className = 'txt';
label.textContent = text; // catalogue metadata — never as markup
chip.title = text;
const x = document.createElement('button');
x.type = 'button';
x.textContent = '×';
x.setAttribute('aria-label', `remover filtro ${text}`);
x.addEventListener('click', e => { e.stopPropagation(); clearOne(key); });
chip.append(label, x);
chipsEl.appendChild(chip);
});
}
selInst.addEventListener('change', () => { state.institution = selInst.value; apply(); });
selType.addEventListener('change', () => { state.type = selType.value; apply(); });
// typing re-tests every tile, so hold off until the burst settles
let searchTimer = null;
searchInput.addEventListener('input', () => {
searchX.hidden = !searchInput.value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { state.search = searchInput.value; apply(); }, 160);
});
searchInput.addEventListener('search', () => { // the field's own clear affordance
clearTimeout(searchTimer);
state.search = searchInput.value; apply();
});
searchX.addEventListener('click', () => { clearTimeout(searchTimer); clearOne('search'); searchInput.focus(); });
selSubject.addEventListener('change', () => { state.subject = selSubject.value; apply(); });
selAuthor.addEventListener('change', () => { state.author = selAuthor.value; apply(); });
function onYear(e) {
let lo = parseInt(loSlider.value, 10), hi = parseInt(hiSlider.value, 10);
if (lo > hi) {
// the handles crossed: push the *other* one along instead of swapping,
// so the thumb under the finger stays the one being dragged
if (e.target === loSlider) hi = lo; else lo = hi;
loSlider.value = lo; hiSlider.value = hi;
}
state.yearLo = lo; state.yearHi = hi;
state.yearTouched = !(lo === decadeMin && hi === decadeMax);
apply();
}
loSlider.addEventListener('input', onYear);
hiSlider.addEventListener('input', onYear);
clearBtn.addEventListener('click', () => {
clearTimeout(searchTimer);
state.search = ''; state.institution = ''; state.type = ''; state.author = ''; state.subject = '';
searchInput.value = ''; selInst.value = ''; selType.value = ''; selAuthor.value = ''; selSubject.value = '';
resetYear();
apply();
});
apply();
toggleFilters(!isMobile); // on a phone the wall matters more than the panel
}
// ─── load atlas + map, build scene ───────────────────────────────────────────
// content-length is the *compressed* size when a server gzips, while the
// reader hands back inflated bytes — so a raw ratio can sail past 100%
const pctOf = (loaded, total) => (total ? Math.min(100, Math.round((loaded / total) * 100)) : 0);
const asMB = bytes => `${(bytes / 1048576).toFixed(1)} MB`;
// read a body in chunks so the wait is legible instead of a frozen line
async function fetchWithProgress(url, onProgress) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
const total = parseInt(res.headers.get('content-length') || '0', 10);
const reader = res.body.getReader();
const chunks = [];
let loaded = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
onProgress(loaded, total);
}
return { blob: new Blob(chunks), total };
}
let lastActivity = performance.now();
let loadFinished = false;
const touchActivity = () => { lastActivity = performance.now(); };
// a bar that stops moving for half a minute is not "almost there": offer a way
// out without tearing down the load that may still be alive
const stallWatch = setInterval(() => {
if (loadFinished) { clearInterval(stallWatch); return; }
if (performance.now() - lastActivity < 30000 || document.getElementById('lento')) return;
const box = document.createElement('div');
box.id = 'lento';
box.textContent = 'o carregamento parou de avançar';
const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = 'tentar de novo';
btn.addEventListener('click', () => {
store('sessionStorage', s => s.setItem(LOAD_MARK, String(Date.now())));
location.reload();
});
box.append(document.createElement('br'), btn);
(document.getElementById('title-screen')?.classList.contains('show')
? document.getElementById('title-screen') : document.getElementById('loading')).appendChild(box);
}, 3000);
async function main() {
// set before anything heavy: if this tab is killed mid-load, the automatic
// reload finds the mark and takes the light path
store('sessionStorage', s => s.setItem(LOAD_MARK, String(Date.now())));
if (previousLoadDied) document.getElementById('aviso-leve').hidden = false;
// the catalogue is megabytes of its own, and until now nothing said so
const loadingMsg = document.getElementById('loading-msg');
const catalogue = await fetchWithProgress('atlas/atlas-map.json', (loaded, total) => {
touchActivity();
// a gzipped response often carries no content-length: count bytes instead
loadingMsg.textContent = total
? `carregando catálogo… ${pctOf(loaded, total)}%`
: `carregando catálogo… ${asMB(loaded)}`;
});
const mapData = JSON.parse(await catalogue.blob.text());
TPR = mapData.size / mapData.tile;
atlasEntries = mapData.map;
const handles = Object.keys(mapData.map);
// the note only earns its space in placeholder mode — the piece count already
// lives in the panel's own result line
const note = document.getElementById('filters-label');
note.hidden = !mapData.placeholder;
note.textContent = 'protótipo — as fotos ainda são um placeholder (uma única imagem de teste, tonalizada por item); os metadados são reais, do catalogo.jsonl';
buildGrid(handles, mapData.map, TPR, mapData.atlases.length);
// ── stats for the title screen: item count, year span, institution count ──
let minYear = Infinity, maxYear = -Infinity;
const institutions = new Set();
for (const entry of Object.values(mapData.map)) {
const meta = entry.meta || {};
if (meta.community) institutions.add(meta.community);
const m = String(meta.date || '').match(/(1[5-9]\d{2}|20\d{2})/);
if (m) {
const y = parseInt(m[1], 10);
if (y < minYear) minYear = y;
if (y > maxYear) maxYear = y;
}
}
document.getElementById('stat-pecas').textContent = handles.length.toLocaleString('pt-BR');
document.getElementById('stat-periodo').textContent =
minYear <= maxYear ? `${minYear}–${maxYear}` : 's.d.';
document.getElementById('stat-instituicoes').textContent = institutions.size || '—';
// ── show the title screen right away — the heavy atlas images load behind it ──
document.getElementById('loading-msg').remove();
const titleScreen = document.getElementById('title-screen');
titleScreen.classList.add('show');
const coverShownAt = performance.now();
const enterLabel = document.getElementById('enter-label');
const progressBar = document.getElementById('progress-bar');
// ── fetch each atlas with byte-level progress, build both the GPU texture
// and the plain <img> (for the card crop) from the same downloaded blob ──
// Decoding four 8192² pages takes about as long as fetching them does, so the
// bar covers both: parked at 100% for the whole decode, it read as stuck.
// phones get the light pages: same layout at 3072², so tiles keep their
// atlas/col/row and the uv math is unchanged
const pageSet = (lightMode && mapData.mobile) ? mapData.mobile : { size: mapData.size, atlases: mapData.atlases };
const nAtlases = pageSet.atlases.length;
const fileBytes = new Array(nAtlases).fill(0);
const fileTotals = new Array(nAtlases).fill(0);
let pagesReady = 0;
let shownPct = 0; // a progress bar that retreats is worse than a coarse one
let painting = false;
// the readers hand over thousands of chunks; repainting on each one is a few
// thousand pointless style invalidations, so coalesce to one write per frame
function reportProgress() {
if (painting) return;
painting = true;
requestAnimationFrame(() => { painting = false; paintProgress(); });
}
function paintProgress() {
const loaded = fileBytes.reduce((a, b) => a + b, 0);
const total = fileTotals.reduce((a, b) => a + b, 0);
const down = total ? Math.min(1, loaded / total) : 0;
// download 70%, decoding 20%, the first render's texture upload the last 10%
const pct = shownPct = Math.max(shownPct, Math.round((down * 0.7 + (pagesReady / nAtlases) * 0.2) * 100));
enterLabel.textContent = total
? `${down < 1 ? 'carregando' : 'preparando'} imagens… ${pct}%`
: `carregando imagens… ${asMB(loaded)}`;
progressBar.style.width = `${pct}%`;
touchActivity();
}
// every response's headers first, bodies second: reading as they arrived meant
// the last file's size landed mid-download and the percentage fell backwards
const responses = await Promise.all(pageSet.atlases.map(async name => {
const res = await fetch(`atlas/${name}`);
if (!res.ok) throw new Error(`atlas/${name}: HTTP ${res.status}`);
return res;
}));
responses.forEach((res, idx) => {
fileTotals[idx] = parseInt(res.headers.get('content-length') || '0', 10);
});
reportProgress();
const blobs = await Promise.all(responses.map(async (res, idx) => {
const reader = res.body.getReader();
const chunks = [];
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
fileBytes[idx] += value.length;
reportProgress();
}
return new Blob(chunks, { type: 'image/webp' });
}));
// Each page is 8192² — ~268 MB of RGBA once it's a texture, and over
// maxTextureSize on plenty of phones (where the upload silently fails, or
// the tab is killed for holding four of them). Cap the page at what the
// device will actually take; at this zoom a tile is ~50 px on screen either
// way. Pages are decoded one at a time and released as we go, so the peak
// is one bitmap rather than the whole set.
const maxTex = Math.min(renderer.capabilities.maxTextureSize, isTouch ? 4096 : 8192);
const atlasMats = [];
atlasSources = [];
for (let b = 0; b < blobs.length; b++) {
const blob = blobs[b];
blobs[b] = null; // the compressed page is not needed once it is a bitmap
const url = URL.createObjectURL(blob);
const img = new Image();
img.src = url;
await img.decode().catch(() => {});
// a refused decode used to fall through as a blank page and a bar that
// never finished; say which page failed instead
if (!img.naturalWidth) throw new Error(`não foi possível decodificar ${pageSet.atlases[b]}`);
let source = img, size = img.naturalWidth;
if (size > maxTex) {
const c = document.createElement('canvas');
c.width = c.height = maxTex;
const ctx = c.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, maxTex, maxTex);
source = c; size = maxTex;
img.src = ''; // drop the full-size bitmap before decoding the next page
}
URL.revokeObjectURL(url);
atlasSources.push({ source, size });
const tex = new THREE.Texture(source);
tex.colorSpace = THREE.SRGBColorSpace;
tex.generateMipmaps = false;
tex.minFilter = THREE.LinearFilter;
tex.needsUpdate = true;
atlasMats.push(tileMaterial(tex, TPR));
// upload this page now rather than letting all four land on the first
// render; the cost then falls inside the bar instead of after it
renderer.initTexture(tex);
pagesReady++;
reportProgress();
// let the bar actually repaint between pages
await new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res)));
}
// dimming rides on a per-instance attribute, so one material per page is
// enough — and an opaque one, which spares the renderer a depth sort of the
// whole wall on every frame
pages.forEach((p, i) => { p.mesh.material = atlasMats[i]; });
setupFilters(mapData);
// The first render hands every atlas to the GPU — on a phone that is seconds
// of frozen main thread, and it used to happen with the bar already at 100%,
// which is what made the loader look permanently finished. Name the step, and
// let the browser actually paint that frame before the freeze begins.
enterLabel.textContent = 'montando o mural… 90%';
progressBar.style.width = '90%';
touchActivity();
await new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res)));
// ── ready — start rendering the wall right away, behind a translucent
// title card, so the wall is already alive when the cover lifts ──
tick();
// two painted frames with every texture on the GPU: the heavy part is over,
// so the next visit — or a reload — takes the normal path again
await new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res)));
loadFinished = true;
store('sessionStorage', s => s.removeItem(LOAD_MARK));
progressBar.style.width = '100%';
enterLabel.textContent = 'entrando…';
enterLabel.classList.add('ready');
document.getElementById('progress-track').style.display = 'none';
document.getElementById('loading').classList.add('ready');
// the cover is a title card, not a gate: it lifts on its own. A short floor
// keeps it from flashing past on a warm cache before anyone can read it.
const MIN_COVER_MS = 1100;
await new Promise(res => setTimeout(res, Math.max(0, MIN_COVER_MS - (performance.now() - coverShownAt))));
const cover = document.getElementById('loading');
cover.style.pointerEvents = 'none'; // the wall is usable from here on, not after the fade
titleScreen.classList.add('dissolve');
// the black waits for the words — a slight overlap so the two readings
// blend into one gesture instead of reading as two separate steps
await new Promise(res => setTimeout(res, 620));
cover.style.opacity = '0';
setTimeout(() => cover.remove(), 1000); // matches the .9s fade in the stylesheet
// ── history opens for business only now that every handle can be resolved ──
// The entry the wall starts from carries no piece and no hash, so the first
// back out of a card always lands on the bare wall and never off the site.
const deepLink = handleFromHash();
history.replaceState({ h: null }, '', location.pathname + location.search);
historyReady = true;
// a link straight to a piece: open it *as* a step, so back returns to the wall
const linked = deepLink && tileByHandle.get(deepLink);
if (linked) openTile(linked);
// on a phone the hint sits over the wall itself — it retires once read
if (isTouch) setTimeout(() => { document.getElementById('hint').style.opacity = '0'; }, 7000);
}
main().catch(err => {
console.error(err);
loadFinished = true;
showFailure('O mural não terminou de carregar.', `${err.message} — tentar de novo carrega a versão leve.`);
});
</script>
</body>
</html>