Static-PWA web map for an urban cycling collective in São Paulo, plus a
self-hosted backend, Python automation scripts, and an RDF ontology.
Local-first and self-hostable, deliberately built to avoid hard cloud
lock-in — the same backend runs standalone on a laptop or self-hosted server
via STORAGE_BACKEND=local, with Cloud Run/GCS (STORAGE_BACKEND=gcs) as
one optional hosted deploy target, not a dependency.
web/ — the app. A static PWA: index.html, one big app.js (single
file, no build step), style.css, sw.js (service worker),
manifest.json, icons, robots.txt, sitemap.xml, llms.txt (guia do
site p/ agentes LLM — aponta pros dumps TTL), lib/ (vendored deps: utils.js,
n3.min.js, energy-worker.js + graph-engine.js (both vendored verbatim
from the sibling sampasimu repo, the canonical v2 energy engine —
re-sync by copying both files and re-applying the AMORA PATCH reqId
echo, see the header comment in energy-worker.js; graph-engine.js
backs the worker’s graph-mode routing, fed by scripts/build-viario.py’s
road network, though amora’s own “Menor energia pelo viário” mode
routes in app.js instead: primary source is the PRE-BAKED binary graph
sampa-viario-graph.bin from build-viario.py --graph (per-node elevations
already sampled at bake time — no DEM/network download per route; decode +
Dijkstra run in lib/viario-graph-worker.js (the buffer is TRANSFERRED to
it; it prices edges with GraphEngine.stepCost, so a graph-engine.js
re-sync from simujaules must keep stepCost(dist, dh, cost)), and
bakedViarioRoute in app.js posts to it; VIARIO_GRAPH_BBOX in app.js
must equal build-viario.py’s GRAPH_BBOX — a segment outside it never
downloads the graph), falling back to the inline
viarioGraphRoute over the South-America FlatGeobuf (range-request
bbox reads via the vendored flatgeobuf-geojson.min.js —
streamFgbFeatures), then that same FGB’s lines rasterized into a
~30 m grid mask (rasterizeRoads). There is no Overpass anywhere
any more — see “OSM layers come from FlatGeobuf” below),
media-pipeline.js (módulo ES com o pipeline de mídia do upload_images.html,
consumido por subir.html — ver acima),
flatgeobuf-geojson.min.js (FGB reader, flatgeobuf 4.4.0),
geotiff/ (geotiff.js 3.0.5 dist-browser, MIT — the COG/DEM reader, loaded
lazily by ensureGeoTIFF; was jsdelivr, and a CDN failure took out all
relief; not precached — the SW caches it on first use),
tom-select.complete.min.js,
tom-select.min.css, qrcode.js, leaflet/ (js+css+images),
leaflet-rotate/ (map rotation, GPL-3.0, vendored VERBATIM as the readable
-src build — see “The map rotates” under Conventions),
locatecontrol/ — Leaflet & friends were vendored off unpkg/jsdelivr;
only app.js’s lazy loads of heic2any, jszip, sql.js and proj4 still hit
jsdelivr (exifr and geotiff.js are vendored); upload_images.html/upload_tour.html load N3 from the
vendored lib/n3.min.js too — upload_images.html’s only remaining CDN
import is heic2any, prefetched WITHOUT blocking the boot and memoized by
promise). Leaflet-based map. Also hosts upload_images.html (per-photo upload
form), upload_tour.html (per-tour upsert form),
backfill_tours.html (mass-backfill applet for missing tour fields),
subir.html (envio simplificado, servido em /subir e no modal do
botão 📤 enviar imgs da barra — só autora opcional + imagens (fotos,
vídeos, artes — no iOS/iPadOS só fotos, stopgap: o seletor de fotos do
WebKit transcodifica cada vídeo escolhido antes de soltar os arquivos, sem
progresso e com o seletor aberto, e parece travado), que sobem ao serem
escolhidas; tudo o mais é o default do
upload_images.html: mesmos POST /upload-image / /upload-video (vídeo
inteiro, sem recorte nem pré-envio), mesma dedup por pHash/vHash; cada
imagem enviada ganha ✎ editar (abre upload_images.html?edit= num overlay
DENTRO da página — o lote segue; o /subir repassa as mensagens do editor) / 🗑
excluir. Streaming: cada item sobe assim que é lido (no toque, um decode
full-res por vez — large, thumb e a miniatura da colagem saem do MESMO
decode do pHash); falha (4G, troca de app, tela apagada) ganha ↻ e re-tenta
sozinha quando a página volta visível/online; POSTs com timeout. Um álbum
por passeio + autora (lst:<slug>, schema:Collection inline, slug
derivado do nome “passeio · autora · data” como o slugifyList do form
completo; a data é a DO PASSEIO), mintado quando o 1º item daquele passeio é
aceito — lote misto = vários álbuns, e o Compartilhar alterna entre eles;
toda mídia integra o seu via schema:isPartOf. A sessão vive no
sessionStorage (phidro:subirSessao, 12 h); phidro-upload-modal-closed
com a página ociosa começa outra. Dedup secundária além do pHash: mesma data
ao segundo + mesmo lugar (~1,5 m) + Hamming ≤ 16 (SAME_SHOT_* — o pHash
muda com o motor do navegador: o mesmo original deu 14 bits entre iPhone e
Chrome). Depois do
lote a página mostra o link /imagens/lista/<slug> e uma colagem 9:16 ≤ 500 kB
(canvas 1080×1920 + compressToTarget, conteúdo dentro da área segura do
story do Instagram: y ∈ [270, 1570]; legenda por célula quando há mais de um
passeio; 🎲 sorteio de 6) com share sheet (navigator.share com arquivo —
no iPhone é o caminho pras Fotos), download e copiar link. O “📍 Ver no
mapa” da galeria STANDALONE navega pra /#midia=<hash>
(tryOpenMediaFromHash → galleryShowMedia, que ESPERA os catálogos de
foto e clipe, avisa se a mídia não tem GPS e abre o popup no moveend do
flyTo) — embutida, segue por postMessage; a galeria só habilita o botão
quando rec.geo (lib/media-query.js marca quem tem schema:locationCreated). O pipeline vem de lib/media-pipeline.js — FONTE ÚNICA do
pipeline de mídia dos DOIS forms (pHash/vHash, variantes, EXIF, moov,
motores de transcodificação): o upload_images.html e o /subir importam de
lá (até a v424 o form completo tinha uma cópia inline de ~1.000 linhas e a
dedup dependia de as duas não divergirem). O FIM do módulo são variantes
enxutas só do /subir (scaledJpeg, compressToTargetLean,
copyExifSegmentFromHead, probeVideoDuration, processClipFile). Os dois
importam como ./lib/media-pipeline.js?api=N: o .js fica até 4 h no
cache HTTP da Cloudflare e HTML novo + módulo velho = erro de link, página
morta — suba o N do form que passar a importar um nome novo),
censo.html (aggregated tour metrics + roster, opened as a modal
iframe from the main app), upload_videos.html (permanent redirect
stub → upload_images.html), and the data/, photos/, clips/,
and tour_assets/<tour_id>/ directories the app reads from at runtime.backend/ — the self-hosted backend. One Flask service (main.py)
that serves web/ as static files and validates+stores incoming
photos. No SQLite; state is split across three RDF catalogs:
web/data/images.ttl (media — ph:StillImage/ph:MotionImage triples;
was uploads.ttl pre-split), web/data/identities.ttl (people —
schema:Person, the single source of truth), and web/data/tours.ttl
(tours + associations + route refs), plus
web/photos/<phash>/{original,large,thumb}.* (image variants).
web/data/data_graphs.ttl is a VoID manifest the frontend still follows,
but the backend no longer mutates it — the dump list is fixed
(CATALOG_DUMPS = tours.ttl + images.ttl + identities.ttl) and
/data/data_graphs.ttl is served from a static shim (DATA_GRAPHS_SHIM).
(a one-shot migration — removed, see git history — carved
uploads.ttl→images.ttl+identities.ttl and re-typed the media classes;
uploads.ttl is now obsolete — and GET /data/uploads.ttl is a hard 404:
a stale July-2026 copy left in the bucket still carried media deleted
later, GPS included, and was publicly served until v425.)
phidro.plist (launchd), requirements.txt, README.md. Runs locally
(macOS/Linux) or on Cloud Run. (Was backend/pi/ — the Raspberry Pi
deploy was retired; the systemd unit + pi-deploy.sh were removed.)shapes.ttl and ontology.ttl live alongside the data
in web/data/; the backend lazily loads them (bucket-first, container
copy as fallback) on first validation — a warm-up thread at boot
(_warm_caches, opt-out PHIDRO_NO_WARMUP=1) does it in parallel with the
first request so nobody pays imports + catalog parse on the first upload —
see _load_validator in backend/main.py. The former top-level ontology/
dir (v1.1 pedalhidrografico.ttl +
JSON-LD context) was removed — git history is the only reference.scripts/ — build-routes.py (full rebuild of routes.json from
web/data/tours.ttl + RideWithGPS — NOT the normal path: the backend keeps
routes.json incrementally on every Tour CRUD; this is for bake/recovery
only, then push via deploy-cloudrun.sh --state), build-clips.py
(re-encodes raw videos in web/clips/raw/ to 360p/720p mp4 + .m4a audio +
thumbnail and writes the triples directly into web/data/uploads.ttl as
ph:Video — local batch tool; the one remaining local catalog-writer,
push via deploy-cloudrun.sh --state-only), deploy-cloudrun.sh (Cloud Run
deploy + --state flag to sync
mutable state; also enables bucket Object Versioning + a lifecycle rule
idempotently — see Conventions), pull-cloudrun.sh (pull mutable state from
the GCS bucket back to local), state-history.sh (list/diff/restore prior
GCS object generations of a state file — the recovery UX on top of Object
Versioning), sync-guard.sh (anti-clobber guard sourced by
deploy/pull-cloudrun — see Conventions), dev-cloudrun.sh (run the Cloud
Run image locally; by DEFAULT in --hosted-data mode — STORAGE_BACKEND=gcs
against the PRODUCTION phidro-state bucket via the machine’s gcloud ADC,
with PUBLIC_BASE_URL pinned to production so tour edits don’t bake
localhost URLs (the PH/96 gotcha) and a best-effort POST /reload to
production on exit (its in-memory caches don’t see out-of-band bucket
writes); --local-data keeps state in the repo files, the old behavior —
the deploy-amora.sh / pull-amora.sh /
push-clips.sh / gcloud-ssh-rsync.sh / pi-deploy.sh family for the
old GCE VM and Raspberry Pi deploys was removed; amora is Cloud Run now),
build-viario.py (data-prep, not runtime: builds the road-network
FlatGeobuf south-america-viario.fgb from Geofabrik’s
south-america-latest.osm.pbf (~4 GB, cached in ignore/) via
osmium tags-filter w/highway + ogr2ogr -f FlatGeobuf with a
script-written minimal osmconf that promotes bridge/tunnel/layer to
real columns (raw OSM values — the client normalizes); --water emits two
more FGBs (south-america-water-areas.fgb polygons +
south-america-water-rivers.fgb river lines; FGB is single-layer);
--layers emits the two MAP-LAYER FGBs that replaced Overpass —
south-america-hidro.fgb (waterway=* + natural=ridge lines, with
tunnel/name) for “Morros e Águas” and south-america-cicloinfra.fgb
for “Cicloinfra OSM” (the collective’s own cycle network —
cycle_network=BR:PedalHidrografico relations, drawn over “Morros e Águas”
— was DROPPED in v421: amora no longer builds, publishes or draws it);
--no-viario skips the expensive 4.5 GB viário build and is what the
weekly CI job runs. The browser reads the .fgb from R2, not GCS:
https://fabdem.pedalhidrografi.co/viario/<name>.fgb (the cameratopo
FABDEM R2 bucket fabdem; CORS open for GET/HEAD, Range works). R2 has no
download fees — via telhas.pedalhidrografi.co (Cloudflare → GCS, files over
its 512 MB cache limit) every byte the reader range-requested was paid GCS
egress (22 Sep 2026: ~10 GiB in 40 min testing the viário layer). CI publishes
to BOTH (build-fgb.yml: GCS, then an R2 mirror via the R2_* repo secrets
— missing secrets = warning, R2 goes stale); the backend (_OG_HIDRO_FGB)
keeps reading GCS (same region, free). The small sampa-viario-graph.bin
stays on telhas (Cloudflare caches it). Two gotchas baked into the script: GDAL sanitizes
cycleway:left → cycleway_left (that’s the name in -select, in
-where, and in props.* on the client), and osmium tags-filter is a
UNION with no value regex — so cicloinfra pre-filters a cheap superset and
the exact predicate lives in ogr2ogr’s -where. All of it —
feeding the browser’s “Menor energia pelo viário” fallback routing and
terrain-mode water/corridors/portals via HTTP range requests against
FGB’s packed Hilbert R-tree (only the bbox’s bytes are fetched — that’s
what made continent-wide coverage viable vs the old full-download ~125 MB
SP-only gpkg); --graph/--graph-only additionally bakes
sampa-viario-graph.bin, the binary CSR graph with per-node elevations
(SP DEM ~5 m where covered, FABDEM elsewhere, via /vsicurl) and
bridge/tunnel decks flattened — the PRIMARY road-routing source the app
downloads (~33 MB gzipped), still clipped to the SP GRAPH_BBOX (DEM
coverage rules; read from the FGB via the GDAL Python bindings).
Elevations get the σ map treatment (--graph-sigma, default 30 m =
app.js’s demSmoothSigmaM) over the FUSED mosaic before per-node
sampling — the baked graph used to be the one routing consumer reading RAW
relief, and the sub-cell noise inflated h₊ (a flat 5.5 km stretch priced
110 kJ raw vs 79 kJ treated, against a 66 kJ route-level estimate). The
fuse also drops SP-DEM cells disagreeing with FABDEM by >100 m
(SRC_DISAGREE_MAX_M): that DEM records ~95k holes as 0 m, and the old
bake sampled them straight (production had nodes at 0.2 m inside São
Paulo). --graph-src accepts a /vsicurl/https://…, so a re-bake needs
no 4.5 GB FGB download — the index serves just the bbox. Upload:
gcloud storage cp --cache-control="public,max-age=86400" ignore/*.fgb
gs://telhas/viario/ — never -Z on the .fgb: Content-Encoding:
gzip breaks GCS range requests, which the client depends on; the graph
bin keeps -Z (gcloud storage cp -Z ignore/sampa-viario-graph.bin
gs://telhas/viario/, downloaded whole). Producer for the consumer
described under lib/graph-engine.js above),
migrate-fabdem-r2.py (migração one-shot do fabdem/ de gs://telhas
pro R2 da Cloudflare SEM egress do GCS: re-baixa o upstream FABDEM V1-2
de Bristol em zips 10°×10° via aria2 segmentado (Bristol throttla
~0,5 MB/s POR CONEXÃO; 16× escala quase linear) e re-aplica a conversão
que gerou o espelho — o fabdem/ do GCS NÃO é o upstream verbatim, é
COG (LZW, blocos 512, overviews 1800/900/450, dos quais o zoom baixo
do cameratopo depende) — e sobe via rclone pro bucket fabdem. Contrato =
ignore/fabdem-migration/manifest.tsv (listagem do espelho); retomável
(pula o que já está no R2), blocos da América do Sul primeiro, guard de
disco (a máquina tinha ~14 GB livres — processa 1 zip por vez). --verify
fecha a paridade por NOME (tamanhos divergem por construção); tiles sem
upstream vão pra missing-upstream.txt (cobertos pelo --egress-fallback,
o plano B pago ~US$0,12/GB). Consumidores a repontar depois (Fase 3):
web/app.js FABDEM_BASE_URL, scripts/build-viario.py FABDEM_BASE,
cameratopo/render.py, quilojaules/app.js),
build-enchente.py (data-prep: assa web/geo/enchente-1929.geojson, a
camada “Enchente de 1929 (cota 724 m)” — banheira sobre o relevo ATUAL
(mosaico DEM de SP + FABDEM, mesma fusão/guarda de buracos do
build-viario.py), só as componentes conexas às sementes nas calhas do
Tietê/Pinheiros/Tamanduateí, cortada a jusante de Barueri (--west);
pip install rasterio numpy scipy shapely, ~15 s; re-rodar e commitar o
GeoJSON — ele vai no container, não no bucket),
audit-captura.py (o motor da auditoria de captura — cruza
tours.ttl + images.ttl + o acervo do Drive e diz, por passeio, o que
falta nos três funis; --sync grava os passes de coleta via
POST /upload-tour mode=patch, --slug-map emite
scripts/whatsapp-slug-map.json pra revisão humana. Lê o Drive em modo
SOMENTE-LEITURA e só metadados — os arquivos são stubs do Google Drive e
abrir um força download; ler os 8 mil puxaria ~13 GiB. Ver docs/CAPTURA.md),
import-activities-censo.py (importa ph:linkActivity da planilha do censo,
casando por URL da rota → data exata → data ±1; NUNCA por número de edição,
que colide),
backfill-activities.py (deriva saída/chegada/movimento/energia medida a
partir da gravação GPS — atenção: a gravação inclui o trajeto de casa até
o ponto de encontro; a janela do passeio é um SUBCONJUNTO dela. No PH/81 a
gravação inteira dá 927 kJ contra os 328 kJ reais do pedal),
ingest-drive.py (fase 1 da ingestão do acervo: só os originais com
EXIF/GPS — os 2 achados críticos da revisão de 07/2026 foram corrigidos em
09/2026: GPS 0/0 é rejeitado (gps_valido) e large/thumb saem convertidos
pra sRGB (para_srgb, sem copiar o ICC de volta); dedup só contra fotos;
rede com certifi + UA próprio), ingest-whatsapp.py (fase 2: os
~6.400 JPEGs + ~1.300 MP4s do WhatsApp, sem EXIF — entram como
ph:StillImage SEM ph:GeoreferencedImage (nunca viram marcador),
dcterms:date = data/hora DO PASSEIO (ph:departedAt ou a do tour),
schema:datePublished = carimbo de compartilhamento do nome do arquivo,
schema:creditText = slug cru de quem compartilhou (join via
schema:alternateName, como o ph:sweepContributor), e
pav:providedBy + prov:wasAttributedTo só quando o
whatsapp-slug-map.json resolve a pessoa; vídeos (--videos) via ffmpeg —
vhash de 8 quadros como o form, audio.webm + 360p.webm + thumb, sem 720p;
rode a fase 1 ANTES: a cópia do zap deduplica contra o original por Hamming
e a versão com GPS vence). Os dois pré-baixam os stubs do Drive em
paralelo (prewarm, 32 threads): lidos um a um materializam a ~250 KB/s
(latência por arquivo), em paralelo ~5× mais; ingest-whatsapp pré-baixa
em lotes de 256),
mock_location.sh (empurra posições de
teste da localização ao vivo pro backend — random walk, 1 ponto/3 s; bate no
remoto amora por padrão, --local p/ 127.0.0.1:8080; curl não precisa de
CORS), exiftool_ph.config. The one-shot migrations (migrate-*.py:
bnodes→IRIs, catalog split, IRI host/slug moves, date offsets, …) and the
legacy build-photos.py / build-routes.mjs / gen-synthetic-rdf.py
were removed in 10/2026 once production showed none of the old patterns —
git history has them if a restore ever reintroduces old data.docs/ — design-reference notes not loaded at runtime. DESIGN.md
(RDF substrate / ontology design rationale), ICON_DESIGN.md (PWA
icon decisions) and CAPTURA.md (captura de dados: os três funis
— chamado / censo / mídia —, o passe de coleta ph:MediaSweep, o ritual
semanal e o plano da fase 2 da ingestão do acervo). Excluded from the
Cloud Run container.capacitor/ — native iOS/Android shell (Capacitor) that wraps the SAME
web app. capacitor.config.json points server.url at
https://amora.pedalhidrografi.co (loads the published site —
location.origin stays amora, so no CORS), with the
@capacitor-community/background-geolocation plugin. The one thing it buys
over the PWA: Localização ao vivo keeps transmitting with the screen off /
app backgrounded (the background bridge lives in web/app.js —
window.phidroLivePush). The second thing (Android today, iOS planned):
the local plugin capacitor/plugins/amora-upload/ — native gallery picker
(the system DOCUMENT picker — the Photo Picker zeroes GPS; measured) + a background upload queue (WorkManager) that
/subir uses when AmoraUpload.info() answers; contract and as-built notes
in docs/PLAN-native-upload.md. Gotchas there: the bridge exists only in
the MAIN frame (/subir in the app’s iframe uses parent.Capacitor), picked
files are read whole from /_capacitor_file_… (Capacitor’s Range is broken;
sw.js must not respondWith that path), and web/ reaches every shell
at once while native builds lag — keep the apiVersion gate.
run-ios.sh / run-android.sh build+deploy to a
physical device via npx cap run without opening Xcode/Android Studio;
npm run icons regenerates app icon + splash from assets/ via
@capacitor/assets. README.md; www/ is TRACKED (it’s the webDir, and
its index.html is the server.errorPath page — “Sem conexão com o Amora”
with Tentar de novo); only android/, ios/, node_modules/ and
package-lock.json are gitignored. run-ios.sh re-applies the Info.plist
keys (camera/Photos usage strings, WKAppBoundDomains, status bar…) on every
run — use --prepare before building from Xcode; WKAppBoundDomains and
ios.limitsNavigationsToAppBoundDomains must never be set one without the
other. Gotchas: on iOS Capacitor loads errorPath on ANY failed or cancelled
main-frame navigation (a same-origin URL that redirects to another host
lands on the offline page), and a same-origin <a download> becomes a
frame navigation in the shell (it killed live sharing) — popups use
dlLinkAttrs(), exports use saveFile(). Edits to web/ alone need NO
native rebuild — the app loads the remote site (so iterate by deploying
web/, not rebuilding); capacitor/ changes do.Todos os IRIs de instância + o vocabulário migraram pra
https://id.pedalhidrografi.co/ e DEREFERENCIAM (padrão httpRange-14). A
Cloudflare faz um 303 path-preserving de id.pedalhidrografi.co/<path> →
amora.pedalhidrografi.co/<path>, onde handlers Flask respondem por content
negotiation: Accept: text/turtle (ou ?format=ttl) → as triples do recurso;
senão a página/documento humano. Esquema (prefixos usados nos TTLs e no código):
| Coisa | Prefixo | IRI | Resolver (amora) |
|---|---|---|---|
| Vocabulário | ph: |
…/terms#<Termo> |
GET /terms (turtle=ontology.ttl | HTML doc) |
| Pessoa | pes: |
…/pessoas/<slug8> |
GET /pessoas/<slug> (turtle | pessoas.html) |
| Passeio | pas: |
…/passeio/<slug8> |
GET /passeio/<slug> (turtle | index SSR’ado servido NO path) — aceita também o slug legível (schema:identifier, mintado do título no save e IMUTÁVEL; slug8 com legível disponível 303a pra ele; ?tour= e id numérico legado 303am pra cá). É a URL canônica dos links (compartilhar/sitemap/feed/memória); routes.json espelha em entry.slug |
| Edição de série | (IRI full) | …/passeio/<ES>/<seq> (ex.: …/passeio/BP/4) |
GET /passeio/<es>/<seq> (turtle | 303 pro passeio) |
| Série | ser: |
…/serie/<ES> (PH/BT/BP/S/SESC) |
GET /serie/<es> (turtle=série+edições | HTML gerada, edições mais recentes primeiro, linkando pro passeio) |
| Mídia | med: |
…/midia/<hash16> (opaco — foto OU vídeo) |
GET /midia/<hash> (turtle | 303 /imagens.html?pick=) |
| Lista/álbum | lst: |
…/listas/<slug> |
GET /listas/<slug> (turtle | 303 /imagens/lista/<slug>, o álbum na galeria) |
| Envio (ph:Upload) | env: |
…/envio/<ts> |
(sem resolver; provenance interna) |
Invariantes: o hash é a IDENTIDADE da mídia e o localname do IRI é ele SOZINHO
(med:<hash16>, opaco — sem discriminador image_/video_; uniforme com pessoas/
passeios). O TIPO (foto/vídeo) vem SEMPRE da CLASSE ph:StillImage/
ph:MotionImage, nunca do IRI; os blobs photos/<phash>/… e a dedup dependem do
hash. Como phash e vhash compartilham o espaço de 16 hex, o backend tem uma
GUARDA cross-type no upload: rejeita um phash que já existe como vídeo (e
vice-versa), senão os dois virariam o mesmo IRI. Links de compartilhamento
antigos (?p=/?pick=image_<hash>) e /midia/image_<hash> seguem funcionando
(o prefixo é tirado na leitura). Nós derivados mantêm o sufixo
_ (pas:<slug>_route, med:image_<ph>_geo|_hash) — o purge do backend é
aritmética de prefixo str(root)+"_", agnóstica ao formato do IRI. Slug =
Crockford base32 (0123456789abcdefghjkmnpqrstvwxyz, 8 chars) pra pessoas e
passeios; validador de tour REJEITA _ (colidiria com o nó derivado). Séries e
edições são chaves naturais (edição = …/passeio/<ES>/<seq>, realizada por
EXATAMENTE um passeio — colisão de numeração vira edição distinta, ex.: BP/3-5;
SHACL ph:SeriesEditionShape impõe isso).
phd: = https://pedalhidrografi.co/data/ (host ANTIGO) ainda aparece: só em
ph:capturedDuring→pas: (migrado), nos IRIs derivados como convenção, e em
menções históricas nos comentários deste arquivo — onde o texto abaixo diz
phd:image_/phd:video_/phd:tour_/phd:assoc_, leia med:<hash> (foto E
vídeo)/pas:<slug>/<…/passeio/<ES>/<seq>>. phd:org_ (organizadores) NÃO
migrou (fora de escopo). A migração foi feita por scripts idempotentes (já
removidos — ver git history), com web/data/tour-iri-map.json (id-numérico
antigo ↔ slug) baked no container.
Continuidade de deep link + gotcha da Cloudflare. Links antigos
?tour=<id-numérico> são preservados: o worker da Cloudflare que fronteia amora
reescreve / → /index.html ANTES de chegar na origem, então index() está
registrado em @app.get("/") E @app.get("/index.html") — senão o SSR por
passeio e o 303 de alias (?tour=<numid> → ?tour=<slug>, via
_tour_iri_map/_legacy_tour_iri) não rodariam via amora. app.js também
resolve o alias no cliente (busca /data/tour-iri-map.json sob demanda) como
defesa. Guid do feed emite o IRI legado phd:tour_<numid> pra passeios migrados
(RSS estável). Resolvers path-based não sofrem com o strip de query (só ?query=
era afetado; paths sempre chegam). Só amora.pedalhidrografi.co/?query= era
afetado — os IRIs id.…/<path> sempre preservam.
Markdown pra agentes (3ª representação) + descoberta. Toda página HTML
negocia também Markdown: Accept: text/markdown (ou ?format=md) devolve a
mesma URL em Markdown limpo. _negotiated_format() é o ÚNICO negociador
(ttl|md|html; _wants_turtle/_wants_markdown são atalhos) e
_markdown_response() monta a resposta (text/markdown, x-markdown-tokens
estimado a ~4 chars/token, Content-Signal — env CONTENT_SIGNAL, default
igual ao do “Markdown for Agents” da Cloudflare, vazio desliga — e
Vary: Accept). Renderers: home = llms.txt + passeios recentes
(_render_home_markdown); passeio (_render_tour_markdown, fatos via
_tour_facts, compartilhados com o SSR HTML); Memória
(_build_memoria_markdown, cache por digest como o HTML); série
(_series_rows alimenta HTML e MD), pessoa, mídia, lista (os três leem o
_load_catalog() cacheado), /terms (_terms_model alimenta HTML e MD);
qualquer outro .html estático vira um resumo — título + descrição +
ponteiros pros dados (_html_shell_markdown). HTML segue o default: */* e
text/html caem nele; só um Accept que PREFERE markdown/turtle sai dele.
Vary: Accept só vai em resposta GERADA (_negotiated()), nunca nos
estáticos que o SW pré-cacheia (index/pessoas/imagens.html): o Cache API
compara o Accept do request guardado (addAll por string, sem Accept) com o de
navegação, e o shell offline pararia de casar. Descoberta (RFC 8288/9727): o
after_request _discovery_links põe um Link em toda resposta 200
HTML/Markdown — api-catalog → /.well-known/api-catalog (linkset JSON
gerado, api_catalog()), service-desc → web/openapi.json (mantido à
mão — atualizar quando um endpoint de leitura mudar), service-doc →
llms.txt, describedby → o manifesto VoID, alternate → o feed. Gotcha
Cloudflare: se uma cache rule cachear o HTML de / ignorando o Accept, um
agente pode receber o HTML cacheado — ou bypassar o cache pra
Accept: text/markdown, ou ligar o “Markdown for Agents” da zona (coexiste:
a CF só converte HTML e passa o text/markdown da origem intacto).
The app is fully static and works offline (service worker). It reads
pre-baked routes.json and resolves Turtle dumps via the manifest at
web/data/data_graphs.ttl (which currently lists tours.ttl and
uploads.ttl). When served by the backend (local or Cloud Run), uploads hit
POST /upload-image or POST /upload-video same-origin; on a static-only
host (CDN) the form is offline-friendly but uploads have nowhere to go.
Photos and videos are described in RDF/Turtle in the
web/data/images.ttl catalog. Both are subclasses of an abstract base
ph:Image (“mídia visual”), which carries the shared shape (date,
schema:locationCreated→schema:GeoCoordinates, ph:capturedDuring,
author, license, provider, schema:isPartOf) via ph:VisualMediaShape
(sh:targetClass ph:Image, applied to both subclasses through the
validator’s inference="rdfs"). SHACL shapes live in web/data/shapes.ttl:
ph:StillImage (foto; rdfs:subClassOf ph:Image, schema:ImageObject) —
phd:image_<phash16> IRI; phash is a 64-bit perceptual hash (DCT-based,
computed in the browser); near-duplicate uploads share an IRI and
naturally cluster. StillImageShape adds the still-only constraints:
bearing (exif:gpsImgDirection), focal (exif:focalLengthIn35mmFilm),
nfo:hasHash, ph:anonymized, ph:compressed.ph:MotionImage (vídeo; rdfs:subClassOf ph:Image, schema:VideoObject) —
phd:video_<vhash16> IRI; vhash is computed by sampling N=8
evenly-spaced frames, taking each frame’s pHash, and majority-voting per
bit into a single 16-hex fingerprint. Doesn’t inherit bearing/focal
(which don’t apply to video). MotionImageShape adds schema:duration,
ph:availableResolution (sh:in of audio/360p/480p/720p/1080p),
ph:audio, optional ph:video360p / ph:video720p, and
schema:thumbnail. (Was ph:Video, renamed in the class-hierarchy
refactor; the phd:video_ IRI prefix is unchanged.)Nested nodes are minted IRIs, not blank nodes. The schema:GeoCoordinates
(schema:locationCreated), nfo:FileHash (nfo:hasHash), and
ph:RouteReference (ph:linkRoute) sub-objects use deterministic IRIs derived
from the parent — <parent>_geo, _hash, _route (e.g.
phd:image_<phash>_geo, phd:tour_1_route). (ph:energyEstimate /
ph:measuredEnergy used to mint _energy / _measured qudt:QuantityValue
nodes too, but were flattened to plain xsd:decimal kJ literals on the tour
— the unit is implicit (kJ) and ph:intensityClassification was dropped, since
intensity is derivable from the kJ value by fixed bands and is computed in
readers.) The trailing _ keeps siblings
distinct (phd:tour_1 never prefix-matches phd:tour_10). This is what makes
deletion/merge trivial: purging a subject = removing (subject, *, *) plus the
<subject>_* derived IRIs (_derived_subjects / _purge_subject in
backend/main.py), with no blank-node-reachability walk. Both the TTL emitters
(upload_images.html, upload_tour.html, backfill_tours.html,
scripts/build-clips.py) and the validator’s re-upload exclude set rely on
this prefix convention. (A one-shot migration, now removed, converted the
historical bnode data — git history holds both.)
Key flows:
south-america-hidro.fgb /
south-america-cicloinfra.fgb from the same host as the viário (R2), through
one shared driver (makeOsmFgbLayer in web/app.js) whose only per-layer
parts are styleFor(props, detail) / tipFor(props). Notes:
osm-overpass even though Overpass is gone — it
is the localStorage key for that layer’s visibility/opacity, and
renaming it would orphan every user’s saved preference (same reasoning
as the historical useViarioGpkg key).zoom >= 13 rule existed
to spare a shared public server; that reason died with Overpass. What
still costs is bytes fetched + Leaflet render, and both scale with
viewport area (which depends on screen size, not zoom level). So:
> OSM_FGB_MAX_BBOX_KM2 (8000) → don’t query; > OSM_FGB_FULL_BBOX_KM2
(1200) → DETAIL_MAIN, where styleFor returns null for the
long tail (ditch/drain/stream, painted bike lanes) so only rivers,
canals, ridges and segregated cycleways draw. OSM_FGB_MAX_FEATURES
is the last-resort cap.streamFgbFeatures(url, bb, **false**) — the third
arg bypasses the shared 10-slot LRU, which they would otherwise fill
with whole viewports of features (they re-query on every pan and
re-render from scratch anyway; the bytes stay in the SW block cache
below).osm-viario) is a third layer on the same driver:
every highway=* from south-america-viario.fgb, white, 3 m of real
width with NO px floor (the collective’s call — at zoom 12 it’s a
~0.09 px veil only visible where streets are dense). It’s a packed: true
source: streamFgbPackedLines projects each vertex straight into a
Float64Array while the FGB streams (no GeoJSON/LatLng objects kept) and
PackedLinesLayer draws them on its own <canvas> in one stroke
(pointer-events: none; CSS-scaled during zoom animation, redrawn on
moveend). L.polyline was ~400 MB of heap for a zoom-12 view (164k
ways); packed is ~55 MB and redraws in ~40 ms. Density (Sé): ~300 ways
and ~80 kB per km², ~50× the hidro — zoom 12 on a laptop = 37 MB, full
HD = 62 MB — hence its own limits (maxKm2 3200 = full-HD zoom 12,
maxFeatures 400k; on touch 800 km² / 120k — the iPhone opens it from
zoom 12) and no DETAIL_MAIN (that FGB has no highway
column). The driver hands load(bb, {maxParts, isStale}) so a
superseded pan or the cap stops the DOWNLOAD, not just the result.show() defers the first refresh to a microtask:
restoreLayerState() runs mid-module, before _flatgeobufPromise /
VIARIO_FGB_URL are initialized (TDZ — a persisted-on “Morros e Águas”
used to boot with only the collective’s network)..github/workflows/build-fgb.yml (free: the repo
is public). It reuses deploy.yml’s keyless WIF and is inert until those
repo vars exist. It runs --no-viario --water --layers, so the 4.5 GB
viário FGB and the baked graph are NOT in the weekly path —
workflow_dispatch has a viario input for those.FGB_CACHE =
'phidro-fgb-blocks-v1' in web/sw.js). Nothing else stores them: the
Cache API refuses 206, Cloudflare BYPASSes (file too big for its cache —
also true on the R2 host). Files > 500 MB are published with
Cache-Control: private (CI, both GCS and R2): with public, Cloudflare
TRIES to cache on every cold key — fetches the WHOLE file from the origin,
ignoring Range, and answers 200 with 1.7–4.5 GB (on GCS that was paid egress
of the full file, each time). private makes it pass the Range → 206 always,
and Chrome’s HTTP cache served ~0 ranges after a browser restart
(measured). Each Range request is split into aligned 64 KB blocks
stored as plain 200s under <url>?__fgb=<etag>&b=<n>; only missing
blocks hit the network, as contiguous runs, deduped across parallel
requests (fgbInflight). The file’s ETag is IN THE KEY (generations
never mix), revalidated every 24 h with a 1-byte range (changed → old
blocks purged; with a stale meta a read waits at most 2.5 s for that
revalidation, then serves the disk blocks — weak 4G used to hang the
first read ~60 s; a failed revalidation retries after 5 min); offline it
keeps serving the known version. FIFO budget
of 4096 blocks (256 MB). The cache name is NOT versioned and is exempt
from the activate cleanup — don’t fold it into VERSION, or every deploy
drops tens of MB of viário. Any failure falls back to plain network.
The SW is off on localhost/127.0.0.1 (app.js unregisters it and
wipes caches for dev), so test the cache at http://amora.localhost:8080
— Chrome treats *.localhost as secure and the dev check doesn’t match
it.oim-power/oim-water/oim-telecoms/
oim-petroleum): the OIM has NO raster tiles any more — only MVT at
openinframap.org/map/<tema>/{z}/{x}/{y}.pbf (CORS open, max z17, 512 px
tiles: the OIM tile z is Leaflet zoom z+1, hence tileSize: 512 and the
coords.z - 1). OimLayer (L.GridLayer) decodes them with the in-file
decodeMvt (no dependency) and paints a canvas per tile with OIM’s own
palettes (OIM_THEMES, rule z = OIM zoom); overzoom past z17 redraws the
parent tile scaled. Click-to-inspect re-hit-tests the decoded tile from the
shared LRU (oimHitTest → oimPopupHtml) and ignores clicks on
.leaflet-interactive/markers/popups and while drawingMode. The SW caches
the host (RUNTIME_HOSTS) — it’s a volunteer project, keep it polite.metro-trens): lines + stations read AT RUNTIME from
busao.bicisampa.info/data/{rail.geojson,rail-lines.json} (repo
danlessa/bicibusaosampa, regenerated weekly from OSM there; CORS open) —
no copy in amora, so a schema change over there breaks this layer
(loadRail reads kind: track|station, ref/refs, and per line
ref/name/operator/color/mode + bikeRules.summary). Its live status
(/api/rail-status) has NO CORS, so the popup shows only the written bike
rule and links to busão. Stations only at zoom ≥ 12; colours from the data
pass a hex-only filter (railColor) before going into a style attribute.web/app.js fetches ./data/data_graphs.ttl, follows each
void:dataDump IRI to load the constituent graphs (currently tours.ttl
and uploads.ttl), parses them with the bundled N3.js
(web/lib/n3.min.js), and renders one Leaflet marker per ph:Image
AND ph:Video (videos use the same photoDivIcon markup as photos with
a red-orange border modifier .photo-dot-video; both participate in the
same density-based clustering via relaxPhotoMarkers). GPS from
schema:locationCreated, popup from triples. The layer panel gained
per-row action icons (fixed column: ▲/▼ to stack inline — the old ordering
modal was removed — plus an action icon: ☰ Rotas / 📍 Compartilhar / ✨
Animação / ⬆ Enviar / ✎ or ⚙ config / 🗑) and persists per-layer
visibility + opacity across sessions. The Destacar rota ★ button in a
route’s modal draws a ~1.5×-thicker copy and materializes the Rota
destacada layer (hidden until a highlight exists; its 🗑 clears it —
addRouteHighlight / clearRouteHighlight / setRouteHighlightRow). In the
trace-edit toolbar the old Editar is now 📂 Carregar (#trace-load,
opens the load-route modal). The Configurações modal
(gear icon in the topbar) lets the user switch between Servidor
(same-origin; persisted source value 'server', legacy 'pi' is migrated
on load), CDN, or Local (kit ZIP file picker — stored in memory, image
URLs become blob URLs). Import/export buttons live there.web/upload_images.html accepts image/*,video/* via a single picker;
each card detects media type from the file MIME and renders the
appropriate body. Image cards: EXIF auto-fill, anonymize/compress
toggles, three variants (original/large/thumb) POSTed to
/upload-image. Video cards: trim sliders + steppers, GPS extraction
from moov ISO 6709 atom, recording-date extraction from
com.apple.quicktime.creationdate (ISO 8601 with TZ regex; falls back
to mvhd binary uint32 seconds-since-1904), browser-side transcoding to
webm/(vp9 or vp8)+opus 360p+720p with the audio embedded in the video
webm (so the ghost-video player has sound on iOS Safari, which mutes
MediaElementAudioSourceNode in some configs); a separate audio.webm
(opus) is also emitted so the audio loop can play without downloading
the full video. Thumbnail from frame ~5% in,
per-card “Apenas áudio” toggle for audio-only clips, POSTed to
/upload-video. Both flows share tour auto-detection (±2h/+12h window)
and the Tom Select people picker with create-on-the-fly. pHash/vHash
dedup prevents accidental re-uploads in the same batch AND against
what’s already on the server (catalog includes both hash sets at boot).
upload_videos.html is a permanent redirect stub to
upload_images.html for bookmarked URLs.
O vídeo é preparado em segundo plano e pré-enviado (v400): o recorte
é identificado por clipParams(card).key (início|fim|áudio-só|hd);
ensureProcessed memoiza o processamento por key em card.proc (um
AbortController cancela o de key diferente em voo) e a preparação
ESPECULATIVA (scheduleSpeculativeTranscode, debounced) dispara na
ativação do card e a cada change do recorte/checkboxes; ao terminar,
stageClip sobe os blobs pra POST /stage-video/<vhash> e o Enviar manda
só o TTL (staged=1) — 409 staging-missing faz o cliente reenviar com os
blobs. processClip escolhe o motor: WebCodecs via a mediabunny
vendorada (lib/mediabunny.min.mjs, MPL-2.0, import dinâmico; só quando o
browser ENCODA VP9/VP8 + opus — Safari não, cai fora) → transcodeClip
(MediaRecorder, passe único, tempo real) → sequencial. A miniatura sai do
próprio passe (CanvasSink / canvas no primeiro quadro ≥ 5 % do recorte).
720p é opcional por card (checkbox HD, default = não-touch, persistido
em phidro:uploadHd); o TTL só emite ph:video720p/"720p" quando o blob
existe — app.js já cai pro 360p. ?slow=1 força o MediaRecorder (debug).
Testado headless (Chrome, scratchpad/e2e.mjs-style): clipe de 8 s
preparado+pré-enviado em ~1,5 s, Enviar em 0,3 s.
Vídeo no iPhone (v416). O MediaRecorder grava WebM quando pode, senão
MP4 (H.264+AAC — o Safari antes do iOS 18.4 não tem writer WebM):
recorderFormats(); cada arquivo E a referência dele no TTL são nomeados
pelo contêiner REAL do blob (clipFileName: .audio.webm|.m4a,
.360p|.720p.webm|.mp4 — o /subir usa o mesmo). O backend
(_CLIP_VARIANTS) tira o sufixo do NOME do arquivo enviado e o
content-type dos BYTES (EBML × ftyp); _clip_keys cobre as 7 chaves
possíveis. No WebKit sem WebCodecs completo não há preparo em segundo
plano: a conversão parte do toque em Enviar/Enviar todas, que chama
blessMediaForGesture SÍNCRONO (libera os <video> e um AudioContext pras
conversões do lote inteiro — tocar com som exige gesto). No toque, preparo
e pré-envio só depois de mexer no recorte/opções (ou já, pra clipe ≤ 20 s);
com saveData, nada é pré-enviado. Orçamento por requisição
REQUEST_BODY_BUDGET = 31 MiB (o Cloud Run em HTTP/1 recusa corpo > 32 MiB
antes do Flask): /stage-video aceita QUALQUER subconjunto dos arquivos,
então um clipe grande sobe em partes; um arquivo sozinho > 32 MiB pede
recorte. Fechar a folha NÃO apaga os cards (ver o contrato
phidro-form-state em Conventions). Hooks: ?mp4=1 (finge sem WebM),
?maxreq=<MiB>. .card { min-width: 0 } é load-bearing no toque (os
inputs de 16 px empurravam o card pra fora da faixa).pyshacl loads web/data/shapes.ttl +
web/data/ontology.ttl once per process. The validator merges the
incoming TTL with the ontology before checking — pyshacl’s ont_graph
parameter does NOT expose ontology-declared instances (like
ph:rwgps a schema:Organization) to sh:class checks, so manual
merging is mandatory. See docs/DESIGN.md §2 for the
full gotcha. validate_image_ttl and validate_video_ttl are siblings
that each pin to their target class + IRI prefix. The validation
universe is scoped (_validation_universe): fragment + ontology + only
the rdf:type triples of the nodes the fragment references (what the
shapes’ sh:class checks need) — plus, for tours, the incoming
ph:inSeriesEdition edges from OTHER tours (the SeriesEditionShape’s
“exactly one tour per edition” inverse-path count). Merging the whole
catalog (~14k triples) cost ~1.3–1.6 s of rdfs closure + SHACL per save
under _validate_lock — i.e. it serialized every upload; the scoped
universe takes ~30 ms with byte-identical verdicts (violations AND
warnings, parity-checked over every tour/video and 60 photos). If a shape
ever needs OTHER catalog context (a new inverse path, a sh:sparql),
extend _validation_universe — don’t go back to merging the catalog.<video>
over the map). Clips come from the SAME parse as the photos
(buildModelFromQuads → lastModelClips → setClipsFromModel() on every
load, so images-geo.ttl is fetched once per boot; loadClipsCatalog() is
effectively loadPhotos, and after clipsCatalog = null it forces a
reload; the advanced-SPARQL N3 store is built on first use,
ensureMediaStore) — files live under ./clips/. Plays through
clips in random order with a 5-state marker handoff (green intro →
pulsing white → orange outro). Clips with audioOnly: true (no
ph:video360p/ph:video720p) are skipped by the ghost-video player but
still participate in the audio loop. An independent “Loop de áudio”
plays the same clips’ audio-only tracks with a longer crossfade for
ambient use. Both have controls in Ajustes and the layer panel.
Audio on the iPhone: WebKit locks HTMLMediaElement.volume, so the
ghost video and both loop <audio> go through ONE AudioContext
(MediaElementSource → GainNode) — that’s what makes fades and the loop
volume work. Those elements need crossOrigin='anonymous' (else the graph
outputs silence); unlock on click/touchend/keydown (touchstart doesn’t
count in WebKit); navigator.audioSession.type is 'ambient' while
Animação or the loop is on (doesn’t pause the user’s music), 'auto'
otherwise. The ghost (and the spotlight) pause while a map-covering modal
is open (mapCoveredByModal(), watching the hidden attribute of
MEDIA_MODAL_IDS) or other media on the page plays.POST /delete-image/<phash> and
POST /delete-video/<vhash> purge the IRI’s triples (plus its <iri>_*
derived IRIs) from uploads.ttl AND delete the underlying blobs from the
store. The frontend popups have a red-orange Excluir button gated by a
confirm() dialog.GET /live-locations e desenha cada pessoa: marcador
divIcon com anel colorido + inicial + (opcional) seta de rumo, esmaecendo
via .is-stale quando o fix envelhece. O rastro é uma linha ligando os
fixes + pontos cujo tamanho reflete a accuracy de cada um (o slider da
camada controla a opacidade dos pontos, _liveBandOpacity). Clicar numa
pessoa abre um popup p/ ajustar cor/opacidade/esconder o histórico dela
(persistido em phidro:livePersonOverrides). O ícone 📍 da linha abre um
modal (apelido + retenção hh/mm do rastro, default 3 h) e liga/desliga a
transmissão da própria posição (token = crypto.randomUUID() por
dispositivo, POST /live-location; no browser via watchPosition, no shell
nativo via background-geolocation com a tela apagada). Ver e transmitir são
flags independentes (_liveViewing / _liveSharing), reconciliados por
applyLiveLocation a cada mudança de Ajustes / visibilitychange /
pageshow. Poll incremental (v416): GET /live-locations?since=<now da
resposta anterior> (since=0 = primeira carga; a resposta traz t0 e os
rastros emagrecidos) — sem since, o formato antigo, mantido de propósito
pro app.js em cache e pro shell. Pontos do rastro [lat, lng, ts, acc, rt]:
o cursor compara rt (quando o servidor RECEBEU), não ts — fixes da fila
offline chegam com data retroativa. POST /live-location aceita age e
points (a fila sem sinal vai junto quando a rede volta); _live_now() só
sob _live_positions_lock. O poll desacelera quando ninguém transmite e
pausa com a aba em segundo plano; um chip “sem conexão” aparece no mapa e as
pessoas esmaecem pela idade REAL do fix. Transmitindo: “Manter a tela acesa”
(wake lock — o WebKit só o concede DENTRO de um toque na 1ª vez da página:
acquireLiveWakeLock() fica SÍNCRONO na cadeia confirmShareName →
applyLiveLocation → startLiveShare), aviso se passou > 1 min sem enviar,
“Retomar” depois de um reload, e o shell guarda o id do watcher nativo
(phidro:liveNativeWatcherId). “Mostrar minha localização” subclassa
L.Control.Locate.LocateControl (o L.Control.Locate é só o namespace) e
SEMPRE sobrescreve onLocationError / onLocationOutsideMapBounds — os
defaults da lib chamam alert(), que no iPhone travava o app num túnel.POST /upload-tour accepts a TTL fragment
describing exactly one phd:tour_<id> a ph:Tour (plus any new
phd:pessoa* / phd:assoc_* declarations it references) and upserts
it into web/data/tours.ttl. Two modes via the mode form field:
replace (default — the TTL is the tour’s complete new state,
purge-and-replace; right for creation) and patch (predicate-level
merge-patch — only predicates asserted in the TTL, plus those listed
in the comma-separated remove form field as CURIEs/IRIs, replace
the existing ones; everything else survives, so clients don’t have
to round-trip predicates they don’t know about). Patch is synthesized
server-side into the equivalent full document inside the state lock
(synthesize_tour_patch), so SHACL validates the FINAL state and the
rest of the pipeline (announcement injection, route sync) is shared.
Both forms use patch for edits; creation stays on replace. An optional
announcement file field is saved under
tour_assets/<tour_id>/announcement.<ext> and wired in as
schema:image <URL> before the triples are persisted (under patch,
a new file also replaces the current schema:image). Next to the original
live two light variants, announcement.web.jpg (long side ≤ 1350 px, the
modal hero / Memória) and announcement.thumb.jpg (short side 256 px, tiles
and person cards) — made in /upload-tour or lazily on the first GET, and
only for art the catalog references. Clients derive the variant URL from
the original’s; every new consumer of the art uses web/thumb, never the
original (the top organizer’s person card went from ~46 MB to ~1.5 MB). To
regenerate, re-upload the art — never delete variants from the bucket: the
302 to them is cached for 7 days, so anyone holding it would get a broken
image.
POST /delete-tour/<tour_id> removes the tour’s triples + its <iri>_*
derived IRIs and purges tour_assets/<tour_id>/; it deliberately does NOT
delete referenced phd:pessoa* or series — git history preserves
those and they may be referenced by other tours.
On every /upload-tour and /delete-tour, the backend also syncs
routes.json incrementally: if the tour (read from the PERSISTED
tours.ttl, not the posted fragment — the entry’s series numbering
resolves phd:assoc_* subjects that live outside the fragment) has a
ph:linkRoute pointing at a RideWithGPS route ou numa rota salva do
próprio amora (…/route/<slug>, provider ph:amora — o segundo provedor
suportado), it fetches the geometry and upserts that tour’s entry (keyed by
tourIri, with a provider field: "rwgps"/"amora", ausente = rwgps
legado); if the link is absent (new/edited tour with no route) or the tour
is deleted, it removes the entry. The RWGPS
fetch runs outside the global state lock (only the JSON read-modify-
write is serialized) and is best-effort — a fetch failure never fails the
tour save (the entry is kept with latlngs:null + error, same convention
as build-routes.py). Rotas amora não têm fetch de rede: a geometria é
decodificada do saved_routes.json (wp + polyline5 sg; POIs dos wp com
isPoi) — e como esse traçado é MUTÁVEL (re-salvar no editor muda), o
curto-circuito de cache de geometria não vale pra elas e o /save-route
re-sincroniza (best-effort, local) os tours que referenciam o slug
(_resync_amora_route_tours). The shared fetch/parse/entry-building logic
lives in backend/rwgps.py, imported by both the backend and
scripts/build-routes.py (single source of truth). The backend reads
RWGPS_API_KEY / RWGPS_AUTH_TOKEN from its environment (best-effort
.env load) — required for private/unlisted routes; public routes work
without. routes.json is mutable state served bucket-first (like
uploads.ttl) via GET /routes.json, with the baked file as seed/fallback.
web/upload_tour.html is the per-tour form (series, sequence, energy
estimate, intensity, attendee/newcomer counts, announcement art; edit
mode via ?id= submits mode=patch + a remove list of the form-
managed predicates left empty); web/backfill_tours.html is the
mass-backfill applet (one card per tour, only the seven backfill
fields — description, departed/arrived, moving duration, energies,
announcement image — sends a per-tour patch of just the changed
fields); web/censo.html shows aggregated metrics + a sortable tour
roster with “Editar” links pointing at upload_tour.html?id=<tour_id>.
The main app exposes Censo through a modal iframe — opened by the
“Censo →” sidebar link in the Routes panel — and the iframe is
re-pointed to ./censo.html on every open so navigating into the edit
form internally doesn’t strand the user there on re-open./data/images-geo.ttl é a fatia que o MAPA carrega. app.js só usa
mídia com coordenada, mas baixava e parseava o images.ttl inteiro DUAS
vezes por boot (manifesto + loadClipsFromUploadsTtl) e descartava o
resto — com a fase 2 da ingestão (~7.500 fotos sem GPS) o dump cresce ~10×.
_images_geo_text() deriva do snapshot: mídia com schema:locationCreated
ou ph:GeoreferencedImage + nós <iri>_* + closure de bnodes + os env:
ph:Upload que a geraram; cache por digest do texto do images.ttl
(padrão do _tours_graph), servida por get_data_ttl com o mesmo
ETag/gzip. É derivada — nunca vai pro bucket nem entra em
CATALOG_DUMPS (duplicaria as triples no universo SHACL). O manifesto
VoID segue listando o images.ttl completo (galeria, censo, pessoas,
memória e agentes precisam do resto); loadAllGraphs troca a URL no
cliente, e o SW trata images-geo.ttl como network-first. Um kit local
exportado carrega a fatia geo (o que o mapa mostra).GET / (deep links ?tour=<id>
— slug8, slug legível ou id numérico legado — 303am pra URL canônica
/passeio/<slug legível ou slug8>; id desconhecido degrada pro index
estático). O SSR por passeio mora em GET /passeio/<slug>: troca
title/description/canonical/OG, injeta JSON-LD NewsArticle + um
<article> renderizado de tours.ttl pra crawlers/no-JS, servido NO
path com <base href="/"> injetado (o app abre o modal pelo path e
remove o nó; render best-effort degrada pro 303 /?tour=). GET /<path:p>,
GET /data/<filename>, GET /photos/<path:p>, GET /clips/<path:p>,
GET /tour_assets/<path:p> (in gcs mode the last three 302-redirect
to the bucket’s public URL), GET /feed.xml (RSS 2.0 dos passeios,
renderizado de tours.ttl e cacheado por hash do catálogo — atualiza
sozinho a cada tour CRUD; link do item = IG, senão a página do passeio),
GET /memoria (a Memória Hidrográfica SSR’ada — URL canônica desde a v404;
/memoria.html faz 301 pra cá preservando a query, as âncoras #<slug8>
seguem funcionando), GET /sitemap.xml (dinâmico, sobrepõe o
estático: home + /passeio/<slug legível ou slug8> por passeio — o app
abre no modal da rota — com bloco Google News pros passeios das últimas
48 h; cache por hash + TTL de 1 h). Ops: GET /health, POST /reload
(force re-read of the on-disk TTL catalog after an out-of-band edit).
Mutations: POST /upload-image, POST /upload-video (staged=1 = só o
TTL, blobs já pré-enviados), POST /stage-video/<vhash> (pré-envio dos
blobs de um vídeo AINDA não catalogado — qualquer subconjunto dos arquivos
por POST, pra caber no limite de 32 MiB por requisição: grava nas chaves
finais clips/<vhash>.* em paralelo + marcador clips/_staging/<vhash>
com o instante; 409 se o vhash já está no catálogo; /upload-video sem
pré-envio recusa com 400 um TTL que cita arquivo que não veio), POST
/stage-video/<vhash>/discard (apaga um pré-envio não confirmado — só com
marcador e sem entrada no catálogo; o form chama via sendBeacon ao
remover o card / pagehide). Pré-envios abandonados são varridos por
_sweep_staging (boot via _warm_caches + 1×/h disparado por
/stage-video): marcador mais velho que STAGING_MAX_AGE_S (6 h) cujo
vhash não está no catálogo → blobs + marcador somem; se está, só o
marcador. O /upload-video que fecha um pré-envio apaga o marcador E as
variantes pré-enviadas que o TTL final não referencia (HD desligado depois
do pré-envio). Não há auth: o pré-envio expõe os blobs na URL pública do
bucket por até 6 h mesmo sem upload — aceito (quem abre o form vai subir).
POST /upload-tour (mode=replace|patch + remove — see Tour CRUD),
POST /delete-image/<phash>, POST /delete-video/<vhash>,
POST /delete-tour/<tour_id>. Rotas salvas (biblioteca do editor de
traçado, web/saved_routes.json): GET /saved-routes (lista resumida,
mais novas primeiro), GET /saved-route/<id|slug> (estado completo, formato
de compartilhamento, + id/slug da rota), POST /save-route (upsert;
body {name, state, id?}; o NOME é obrigatório e ÚNICO — vira o slug do
link; colisão com outra rota → 409; responde logo depois do JSON write +
resync dos tours — o card OG renderiza em THREAD), POST /delete-route/<id>. Cada rota
salva tem um link compartilhável POR NOME /route/<slug> —
GET /route/<slug> serve uma página mínima com as OG tags da rota
(og:image = /route/<slug>/og.png — o card de WhatsApp/redes: traçado
renderizado com Pillow sobre a Morros e Águas (o MESMO FGB de
hidrografia da camada, lido server-side por range request via o pacote
flatgeobuf em storage.googleapis.com — a Cloudflare 403a UAs não-browser)
_refresh_route_og_async — FGB + Pillow levam ~2 s e o
usuário esperava isso tudo pra ver o link/QR; save_route não usa
@serialized de propósito, só o miolo read-modify-write trava) e
persistido em route_og/<id>.png no store; o GET cai em memória → blob →
render lazy (rede de segurança: no Cloud Run a CPU fora de request é
throttled e a thread pode atrasar; _og_render_lock evita render duplo);
delete-route apaga o blob; max-age 1 h)
e redireciona o humano na hora
(script + meta-refresh) pra /#rt=<slug> (FRAGMENTO, como o #st=:
nunca é comido pelo strip de query da Cloudflare nem pelo cache do SW —
e o SW trata /route/<slug> como network-first pra rename não servir
redirect velho); o cliente resolve (tryLoadSavedRouteFromHash → fetch de
/saved-route/<slug>); depois de carregar tira o #rt= da URL e ADOTA
id/nome (re-salvar atualiza a MESMA rota; cópia = salvar com outro nome).
Slug inexistente segue no 303 antigo (o app abre com o toast de erro).
O suporte antigo a /?route=<id> foi REMOVIDO. No modal Salvar, ☁ Salvar
no servidor, Copiar link e QR salvam no servidor (link/QR também
compartilham; sem backend degradam pro #st=); colisão de nome com OUTRA
rota → 409 com id/name da existente e o cliente pergunta se é pra
atualizá-la (re-salva adotando o id). ⤓ Exportar GPX é o download. O
modal 📂 Carregar lista as rotas em GRADE de cards (miniatura SVG do
traçado via preview/distMeters/stats que o GET /saved-routes agora
devolve — a subida vem de stats.ascentM, gravada pelo editor no save
porque o estado salvo não tem elevação); o 🔗 de cada card copia o link.
O rascunho do traçado persiste em localStorage (phidro:traceDraft:v1,
gravação debounced a cada mutação): Cancelar/Esc só fecham o editor e o
próximo Traçar restaura; o descarte real é o 🗑 da barra de edição. Localização ao vivo (efêmera, EM MEMÓRIA —
NÃO toca os catálogos): POST /live-location (upsert da posição + rastro de
um token pseudônimo; body JSON {id, name?, lat, lng, accuracy?, heading?,
ttl?, age?, points?} — points = a fila offline; rastro thinned por
tempo/distância, teto 500 pts/pessoa e 500 pessoas), GET /live-locations
(posições não-expiradas + rastro de cada uma; ?since= = só o que mudou,
ver Localização ao vivo; Cache-Control: no-store, sem ETag),
POST /live-location/stop (apaga
o próprio token na hora — NÃO chamado automaticamente; fica p/ um “apagar
meu rastro” explícito). Estado em _live_positions (dict por token sob
_live_positions_lock), retenção por token (ttl em s, default 3 h, teto
24 h, podada em _prune_live). CORS é aberto SÓ nestes endpoints
(_LIVE_CORS_ORIGINS = capacitor:// / ionic:// / http(s)://localhost,
via @app.after_request _live_cors) pro shell nativo Capacitor — uploads/
CRUD seguem same-origin. Estado por-processo: no Cloud Run exige a
instância fixa em 1 — deploy-cloudrun.sh pina --max-instances=1
(default; o serviço rodou com max=5 até 09/2026, quando o catálogo também
ficava exposto a lost update entre instâncias — o store não tem
precondição de geração). min=0 continua o default por custo; o warm-up de
boot amortece o cold start.Source videos in web/clips/raw/ (.MOV/.mp4/.m4v). Run:
python scripts/build-clips.py
Requires ffmpeg and exiftool in $PATH (Homebrew on macOS, apt on
Linux). For each source the script:
exiftool (clips with no GPS are skipped); reads both
CreateDate (mvhd) and Apple CreationDate (iOS, with TZ) and prefers
the Apple value — Apple’s is the real recording time; mvhd is the save
time and is often misleading. Pass -api QuickTimeUTC=0 so the TZ is
preserved.<stem>.360p.mp4 (always) and <stem>.720p.mp4
(best-effort, opt-in via clipsGhost.useHd) into web/clips/.web/clips/audio/<stem>.m4a (AAC 96k).web/clips/<stem>.thumb.jpg (~256px short side, JPEG quality 4).web/data/tours.ttl and associates each clip with the closest
tour within ±12 h via ph:capturedDuring (skipped if no tour matches).web/data/uploads.ttl as a ph:Video
with deterministic IRI phd:video_<md5(stem)[:16]>, with default
author/provider phd:pessoaDandan and CC BY-SA 4.0 license. Idempotent
upsert — re-running purges + rewrites the same IRI’s triples cleanly.The mtime check on the transcoded files makes re-runs cheap. Adding a new
clip = drop into raw/ and re-run.
There is no clips.json — that intermediary was removed; build-clips.py
writes RDF directly. App.js reads ph:Video from uploads.ttl only.
WATER_COLOR (#867627), não azul. Decisão do coletivo: rio
do interior carregado de sedimento é amarelo/marrom/preto; azul lê como
oceano. Toda camada de água nova (preenchimento, linha, ponto) usa
WATER_COLOR / WATER_COLOR_DARK (contorno), declaradas antes da seção do
OpenInfraMap em web/app.js — inclusive desviando da paleta de uma fonte
externa (o OIM pinta água de azul/lilás). Exceção consciente: “Morros e
Águas” (e o card OG que a espelha no backend) seguem o verde/ocre da folha
de estilo JOSM homônima.leaflet-rotate could be bundled; GPL-3.0 and AGPL-3.0 combine via
§13 of each). Every vendored dep must stay AGPL-compatible (today: BSD,
MIT, Apache-2.0, MPL-2.0, GPL-3.0 — see README’s License section; note
Apache-2.0 is NOT GPL-2.0-compatible, so don’t “go back”). The Ajuda modal
links the source (AGPL §13) — keep that link.leaflet-rotate 0.2.8, web/lib/leaflet-rotate/,
loaded before app.js; map created with rotate: true). Rules:
mapPane into rotatePane (tiles/vectors)
and norotatePane (markers/tooltips/popups), but a pane created WITHOUT
a container lands in mapPane and does NOT rotate. So layer panes are
map.createPane(name, ROTATE_PANE) and marker panes
map.createPane(name, NOROTATE_PANE) (constants next to the phlyr-*
loop). A marker pane left in mapPane also stacks ABOVE popups (the whole
norotatePane sits at z 400).PackedLinesLayer sizes its canvas to the box of the
four containerPointToLayerPoint corners and redraws on rotate.rotate (every step). setupMapRotation adds a
debounced rotateend (photo relaxation listens); the FGB driver
refreshes on moveend rotate (rotating exposes new corners —
getBounds() covers all four).rotateControl and shiftKeyRotate are OFF on purpose:
amora has its own north button (hidden at bearing 0, click animates back),
its own Shift+wheel (the plugin reads only deltaY, which macOS zeroes
with Shift), and a 15° pinch dead zone (ROTATE_DEADZONE_DEG, via a
per-instance map.setBearing wrapper active only during two-finger
touches) — without it every pinch-zoom tilted the map. Bearing is not
persisted across sessions (the tab session below restores it on a
same-tab reload/discard). Don’t edit the vendored file; adjust in
setupMapRotation, which also carries two instance patches for the
plugin’s two-finger handler: (5) a still two-finger tap cleared
_zooming but left _rotating and the document listeners behind, so the
next pinch jumped to NaN / lat −90; (6) the core TouchZoom the plugin
replaces stayed live in map._handlers (two moves per pinch) — disabled.index.html carrega <base href="/">. Abrir um passeio reescreve a
barra pra /passeio/<slug> (_setTourUrl, replaceState) e, sem o base,
toda URL relativa construída DEPOIS (./data/tours.ttl do resumo do
modal, ./saved-routes, ./upload_tour.html…) resolvia um nível abaixo →
404 silencioso (o resumo do passeio nunca carregava pra quem entrava pela
home). O SSR de /passeio/<slug> já injetava esse base; agora o estático
traz e a injeção é idempotente (if "<base " not in html_text). CSP tem
base-uri 'self'; os href="#" do app são todos preventDefault’ados.
Consequência: em JS, NUNCA new URL(rel, location.href) — ele ignora o
base; use document.baseURI (o fetchManifest errava isso e as fotos
sumiam do mapa e da tira em todo link /passeio/<slug>).phidro:session:v1, sessionStorage). Vista, rumo, rotas
destacadas e localização ligada, gravados no moveend/rotateend
(debounce), ao destacar/limpar e no locateactivate/locatedeactivate —
o iPhone descarta abas em segundo plano e o reload voltava pro
enquadramento inicial no meio do pedal. Restaurada no boot só SEM deep
link (_bootHasDeepLink: /passeio/, ?tour=, #st=, #rt=,
#midia=); a localização volta com locateControl.start() sem recentrar.makeCloseDot entra como PRIMEIRO
filho (prepend) e o addMaximizeDot logo depois; no toque a bolinha de
fechar é sticky — modal novo: prepend a bolinha. Maximizar só persiste no
desktop (> 760 px), nunca no celular. Folhas fechadas ficam inert
(syncSheetsInert; o controlador de a11y só devolve o que ele mesmo tornou
inert). O #tour-article do SSR é escondido ANTES do L.map e um
ResizeObserver no #map mantém o tamanho do Leaflet — não devolver o
artigo pro grid do body (era o mapa cinza de todo link de passeio). Pinça
na interface: gesturestart/gesturechange cancelados no app e nas
páginas EMBUTIDAS (só embutidas — standalone mantém o zoom) +
overscroll-behavior: contain. Leitura de localStorage em nível de
módulo passa por storage.get (com “Bloquear todos os cookies” o Safari
lança no getter e o app não abria). Toque × hover: as regras de hover de
bolinhas de foto, cone, tira e .secondary-btn ficam dentro de
@media (hover: hover) — o iOS aplica :hover no toque e ele gruda. A
bolinha de fechar NÃO sai de vista ao rolar: nas folhas é sticky (1º
filho); no painel de Camadas, no toque, só .layer-rows rola (flex column —
bolinha, título e “☰ Rotas” ficam); na sidebar de rotas é sticky com
top: -32px (o sticky conta a partir da borda do CONTEÚDO do contêiner que
rola — o padding de cima entra na conta, no Blink e no WebKit). Filas de
ação no pé de folha que rola (compartilhar localização, ficha da foto) são
sticky bottom: 0 no toque, e ali o “mais ↓” some (cobriria os botões).
“🔍 Ver grande” é um botão com rótulo em .photo-actions (era a bolinha
verde sem texto). VoiceOver: marcadores de foto/vídeo ganham aria-label
(mediaA11yLabel — tipo · passeio · data · autoria) no 'add' (o Leaflet
recria o ícone a cada add), as linhas da lista de rotas são role=button
(Enter/Espaço) e o slider de datas tem aria-valuetext.phidro-form-state (forms embutidos → app). Cada form que roda
numa folha manda, só embutido, a cada mudança e uma vez no load,
{type:'phidro-form-state', busy, dirty, label, keepsOnClose?} (busy =
envio/conversão/pré-envio/leitura em curso; dirty = algo não enviado;
label = o que se perderia, pt-BR). Produtores: subir, upload_images,
upload_tour, backfill_tours. Consumidor: app.js (formPending /
confirmFormClose / confirmFormNavigate + _modalClosers): toque no
overlay NUNCA fecha folha de formulário; fechar um form busy/dirty
pergunta — exceto com keepsOnClose (o upload_images guarda os cards e o
lote segue; reabrir mostra tudo); NAVEGAR o iframe sempre pergunta
(descarta de verdade). O app nunca manda phidro-upload-modal-closed com
o form ocupado; {type:'phidro-upload-modal-closed', discard:true} é o
descarte explícito. Rascunho do form de passeio em
phidro:tourDraft:<slug|novo>.saveFile() (lib/utils.js) é o ÚNICO
caminho. No toque ou no shell nativo, navigator.share({files}) (é como
um arquivo chega em Arquivos/WhatsApp/Garmin no iPhone); senão
<a download>; devolve 'shared'|'downloaded'|'cancelled' — toast só nos
dois primeiros. Chame DENTRO do toque, sem await antes: o share() do
WebKit consome a ativação transitória (~5 s); arquivo que fica pronto
depois (o .zip de fotos) cairia no download — no toque, needsShareTap
detecta isso e oferece uma faixa “💾 Salvar .zip” (showActionToast) cujo
toque chama o saveFile dentro do gesto. Chips de download em popups usam
dlLinkAttrs() (sem download no Capacitor). .zip grande no celular:
buildStoreZip (STORE + CRC-32, ~1× o payload na memória), não JSZip
(~3×). A fonte de fotos 'local' (kit) nunca persiste._dumps). Cada dump fica em memória
como TEXTO (o que /data/<ttl> serve) e como GRAFO rdflib VIVO, parseado
uma vez e mutado in place pelos RMW — nada de STORE.read_text + parse +
serialize de ~430 KB por gravação (era ~120–240 ms por foto, ×6 leituras
num save de passeio, e em GCS cada leitura era HEAD+GET). Regras: mutação
SEMPRE via with _mutating("images.ttl") as g: (grafo vivo sob
_state_lock; commita ao sair — serializa, grava, atualiza o texto,
invalida o snapshot; se o corpo ou a gravação levantar, descarta o grafo
vivo e o próximo acesso re-parseia o último estado PERSISTIDO — memória e
store nunca divergem); o grafo vivo (_dump_graph) só é tocado sob o lock,
iteração inclusive (add/remove concorrente a uma iteração estoura o store
de memória do rdflib); leitura fora do lock usa _load_catalog(), um
SNAPSHOT imutável da união refeito preguiçosamente depois de cada commit
(~14k adds, dezenas de ms). _tours_graph() (feed/SSR/route-sync) segue
cacheado por digest do texto. Escritas fora de banda no bucket
(state-history.sh restore, deploy --state, edição manual) continuam
exigindo POST /reload, que agora zera texto E grafos._load_validator seta owlrl.DeductiveClosure.improved_datatype_generic =
True + use_RDFLib_lexical_conversions(). Sem isso, cada closure rdfs do
pyshacl instalava use_Alt_lexical_conversions() GLOBALMENTE e restaurava
no fim — e um Turtle parseado por OUTRA thread nesse intervalo saía com o
offset dos xsd:dateTime deslocado uma hora (-03:00 → -04:00). Como
cada gravação re-serializava o catálogo, o desvio acumulava: 435 das 460
datas de mídia chegaram a offsets até -23:00 (wall-clock certo, offset
errado — conferido contra o EXIF dos originais). Reprodutível com um
harness de threads; o acumulado foi reparado em 09/2026 (o script de
migração saiu do repo — git history)./upload-image E /upload-video (este era @serialized inteiro) recebem
o corpo, validam (só o pyshacl.validate serializa, sob _validate_lock)
e gravam blobs FORA do _state_lock; só o RMW do catálogo, com
re-checagem TOCTOU da colisão cross-type, roda sob o lock. Não reintroduzir
@serialized num handler que lê request.files./imagens/lista/<slug>[/<n>]. O backend (album_page) serve o
imagens.html nesse path com as tags de preview de link (og:/twitter:)
do álbum ou da n-ésima mídia injetadas depois do <title> (o <title> fica
— o cliente usa ele de base do título da aba); turtle/markdown →
list_page; álbum desconhecido ou falha → o estático puro. A imagem do
preview é GET /imagens/og/<h1>[-<h2>[-<h3>]].jpg (Pillow, 1200×630 JPEG
~100 KB — o WhatsApp recusa og:image grande): 1 hash = a mídia inteira
sobre ela mesma desfocada (+ ▶ se vídeo); 2–3 = a capa do álbum em faixas.
Endereçada pelo conteúdo (max-age 1 dia na borda), só aceita hashes do
catálogo e fica só em memória (LRU de 64) — um GET não grava no bucket.
/listas/<slug> faz 303 pra cá e ?list=<slug> é reescrito no cliente. Por
isso o imagens.html traz <base href="/"> — e new URL(rel,
location.href) NÃO respeita o base: use document.baseURI. Com a faceta
Listas numa lista só (standalone), a barra mostra o álbum; com uma foto
aberta, /<n> = posição dela na ordem CANÔNICA do álbum (albumSequence:
a visão padrão, agrupada por passeio — independe do agrupamento de quem
compartilha). A ordem total é imposta em JS (canonicalRowOrder: data
desc pelo instante, sem data no fim, desempate pelo IRI) em toda consulta
de facetas — o Comunica NÃO aplica a 2ª chave do ORDER BY DESC(?d) ?m de
forma confiável (medido: mesma data / sem data saíam embaralhadas). O
backend ESPELHA essa ordem em _album_sequence pro preview do /<n>
mostrar a mesma foto que o link abre — mudou canonicalRowOrder,
groupRows ou groupOrderStr, mude _album_sequence junto (paridade
conferida em 09/2026 contra os 10 álbuns + um álbum com as 822 mídias).
Abrir a foto faz pushState (voltar = fechar);
deslizar/setas fazem replaceState; embutida no iframe do app não mexe em
URL/histórico. O n desloca se o álbum ganhar/perder mídia antes dela — o
link durável de UMA mídia segue sendo /midia/<hash>. O SW serve essas
navegações do cache do imagens.html. Anterior/próxima (deslizar, ‹ ›, ←/→)
seguem a ordem da grade NA TELA. No toque a grade abre a foto no
pointerup e o click que vem depois cairia no lightbox (fechava a foto,
ou clicava num link do painel): _lbOpenedAt engole esse click fantasma.imagens.html) usam o thumb.jpg; o large.jpg
(2400 px) só com tile grande e, em aparelho de toque, poucos na tela
(wantLargeTiles).
Não voltar ao srcset "… large.jpg 2x": DPR 2–3 (todo celular) escolhia o
large em TODO tile, e o WebKit decodifica em tamanho cheio o que está
visível (sem subamostrar abaixo de 5 MP, ~17 MB cada) — o álbum de 64 fotos
do PH 113 passava de 1 GB e o Safari do iPhone matava a aba (“A problem
repeatedly occurred”). Pelo mesmo motivo a View Transition da galeria só
roda com ela ≤ ~2 telas de altura: o snapshot do WebKit é do elemento
inteiro, não da parte visível. No toque o large.jpg vai só pros
LARGE_MAX_TILES (11) tiles mais perto do centro da tela
(reconcileLargeTiles, IntersectionObserver, despejando o mais longe) e a
grade tem no máximo 9 colunas. A tira de fotos do modal do passeio
segue a mesma regra: só miniaturas (large.jpg só no visualizador) — ela
derrubava o Safari nos passeios grandes.MQ.facetRows, em lib/media-query.js) — tem que continuar EQUIVALENTE ao
buildQueryFromFacets (mude os dois juntos); o Comunica só carrega no
Avançado (SPARQL), e albumSequence é síncrono. A faceta de data usa a
data de CALENDÁRIO gravada (SUBSTR(STR(?d),1,10)): o Comunica aplica o
fuso do navegador, com o sinal trocado, a limites de data sem fuso (pedais
noturnos sumiam). Standalone, o lightbox empilha histórico também fora de
álbum (estado {phLb, iri}; voltar fecha a foto); embutida, nunca mexe em
histórico. Mensagens: phidro-gallery-back (← Mapa, toque),
phidro-gallery-show, phidro-gallery-reload (galeria → app) e
phidro-gallery-pick {hash} (app → galeria: “Ver grande” abre a foto no
mesmo documento, sem recarregar). No modo seleção do toque, “Selecionar
tudo” e a contagem ficam no ☰ e na barra de baixo (a barra de cima não
quebra linha).hit invisível na pane de
rotas (ROUTE_HIT_WEIGHT: 22 px no toque, 10 px no mouse) — caminho novo
de mostrar/esconder rota adiciona/remove r.hit junto; no Traçar o toque
nela adiciona ponto (e foto/clipe perto do toque ganha da rota). Um toque a
≤ 16 px da borda de uma bolinha abre a foto/clipe mais próximo. O
focusRoute usa routeFitOptions() (no celular, padding de baixo = altura
da folha). Relaxação das fotos = base + tick (computeRelaxBase /
relaxFromBase), sempre via scheduleRelax(rebuild); commitRelax só
escreve valor que mudou; --photo-scale fica no container do mapa, não em
cada marcador. O slider de datas atualiza a lista no input e as fotos só
no change (ou 250 ms parado).tp._routeSeq) e um resultado só entra se o carimbo, o
vizinho anterior e as duas pontas ainda batem; pendingRouteSeq virou a
ÉPOCA do rascunho (desfazer/descartar/inverter/carregar incrementam).
Trecho pendente fica marcado no snapshot e sweepPendingRoutes re-pede o
que sobrou quando nada está em voo. O histórico é empilhado ANTES do
await e completado por patchPendingHistory (o push tardio apagava o
refazer). Rotear em lote: routeSegmentsBatch(tps), por referência, nunca
por índice. Os snapshots do desfazer COMPARTILHAM os arrays de
pathFromPrev — nunca mute um path no lugar, substitua; HISTORY_MAX =
#st=/#rt=, rota salva, GPX, “Editar este traçado”
ou “Traçar a partir daqui” guarda o rascunho deslocado em
phidro:traceDraft:prev (prepareEditorReplace / announceStashedDraft,
“↺ Restaurar” no aviso e no 📂 Carregar); saveTraceDraft não faz nada
fora do editor ou com o mapa vazio, e só o 🗑 apaga o rascunho. O vínculo
com a rota do servidor (id/nome) é por “linhagem” (_lineageMeta) — mudou
id/nome fora do pushHistory, chame syncLineageMeta(). No toque,
inserir no meio da linha exige SEGURAR (300 ms, 8 px; 2º dedo cancela).
GPX em Reta com > 200 pontos vira ~150 pontos editáveis (Douglas–Peucker
por importância) com a geometria exata no pathFromPrev. Gotcha do
Leaflet: bubblingMouseEvents:false só vale em layer que ESCUTA o evento —
por isso a linha do rascunho tem um listener de click vazio (sem ele o
clique na linha vazava pro mapa e criava ponto no fim).touchRoutingMemory /
releaseRoutingMemory soltam o worker do grafo, os tiles de DEM, o LRU do
FGB e os produtos do viário 30 s depois de sair do editor (ou 5 min parado
dentro dele). DEM (SP, FABDEM, custom) SÓ pela seção “Leitura de COGs”:
openCogHandle (4xx = definitivo; rede/5xx/timeout = toast + nova
tentativa em 30 s, e os pontos de uma fonte pior são refeitos quando a
boa volta), demTile (LRU de tiles 512² com orçamento em bytes — 24 MB no
toque, 64 MB no desktop — e abort contado por referência),
sampleDemPoints (perfis: resolução cheia, só os tiles com pontos) e
loadDemHandleMosaic (terreno e “Estimar” da Câmera: o overview mais
grosso ainda ≤ 1″ — no DEM de SP, o IFD2 de ~21 m). Não voltar a ler
janelas do IFD0 sobre a bbox inteira (34 MB e 30 s de long tasks por trecho
de 9 km). O grafo baixa por fetch simples (o SW é quem o guarda —
phidro-graph-v1), com timeout de INATIVIDADE de 20 s e progresso.
energyRoute(from, to, mode, superseded) devolve null quando cancelado —
quem chama não grava esse null. Gotcha do FGB: o flatgeobuf 4.4 pede
todos os lotes de feições de uma vez depois do índice e não aceita
AbortSignal; o cancelamento marca os range requests de cada consulta com
o header x-phidro-fgb-query (5º argumento do deserialize) e
installFgbFetchAbort (wrapper do window.fetch) troca o marcador pelo
signal da consulta e o remove antes da rede — numa atualização do
flatgeobuf, confira que ele ainda chama o fetch global com esses
headers. streamFgbFeatures(url, bb, useCache, {isStale, signal, maxParts,
keep}): no teto devolve o parcial com .capped; stale rejeita com
AbortError. makeOsmFgbLayer reaproveita a última carga completa enquanto
a vista couber nela (padFrac/density0; limites podem ser função). Viário
OSM: 800 km² / 120 mil vias no toque (abre a partir do zoom 12), 3200 / 400
mil no desktop; PackedLinesLayer cobre o quadrado da diagonal com o mapa
girado (redesenha só quando um canto sai dele), acompanha a pinça pelo
evento zoom e limita o DPR do canvas a 2. Trocar uma fonte de dados só
re-roteia os trechos que dependem dela (proveniência path.mode): parâmetro
novo que afete o roteamento entra em routingSettingsNow() e em
segmentDependsOn().showPicker() não faz nada no iOS em
input de data (e não lança): no toque o padrão é um input transparente
POR CIMA do controle (.dt-overlay nos forms de passeio,
.date-pick-overlay no filtro de datas do mapa); showPicker() só no
desktop. A narrativa (dcterms:description) vai como literal de UMA linha
com escapes — o multipart/form-data transforma quebra de linha nua em CRLF;
não voltar pros """longos""" nos forms. Submissão implícita (Enter) é um
clique simulado no botão default (o ev.submitter não distingue): bloqueie
o Enter no keydown. Inputs com 16 px no toque (menos que isso o Safari
dá zoom ao focar).lib/utils.js (escapeHtml,
turtleEscape, TTL_PREFIXES, slugifyList — tem que seguir igual ao do
app.js, os slugs de álbum dependem disso —, randPersonSlug, loaders de
N3/Tom Select/scripts, setupEmbeddedPage, helpers do form de passeio) e os
tokens de cor em lib/pages.css (só tokens, linkado ANTES do <style> de
cada página). As páginas importam ./lib/utils.js?api=N — suba o N de quem
passar a importar um nome novo (mesmo motivo do media-pipeline.js); o
app.js importa sem query (vem do mesmo precache). Não recopie esses helpers
numa página. Exceção: memoria.html é script clássico e tem os seus.postMessage — phidro-media-changed
(upload_images.html: envio, edição) e phidro-tour-changed
(upload_tour.html e backfill_tours.html: save, delete). O app marca o modal como “sujo” e só
chama reloadPhotos() ao fechar se algo foi salvo; fechar sem salvar não
custa mais 4 dumps + rebuild de marcadores. Um novo caminho de escrita num
form precisa emitir a mensagem, senão o mapa só atualiza no próximo reload.transcodeClip em upload_images.html:
um <video>, dois canvases no mesmo rAF, três MediaRecorders — áudio-only,
720p, 360p — com a trilha de áudio clonada). MediaRecorder é tempo real:
os três passes em série custavam 3× a duração do recorte. Falhou (browser
recusa 3 recorders)? Cai pro sequencial antigo, que fica no arquivo por
isso. Acima dos dois está o caminho WebCodecs (transcodeClipFast,
mediabunny — segundos em vez de tempo real); a cadeia mora em
processClip. Uma mudança no modelo de saída (bitrates, lado curto,
codecs) tem que ser feita nos DOIS motores (VIDEO_BITRATE é compartilhado)./upload-video grava os blobs em paralelo (_write_clip_blobs,
ThreadPoolExecutor — eram 4 round-trips em série no GCS) e lê
request.files antes de qualquer thread; continua fora do _state_lock.sw.js VERSION on any change to files in web/ —
otherwise the service worker serves stale cached copies and the change
won’t reach users. It’s a monotonic phidro-vN integer counter; just
increment. For user-visible changes, also add an entry at the top of the
<dl class="changelog"> in web/changelog.html (dated, keyed to the new
vN). That page IS the changelog: the Ajuda modal’s
<details id="help-changelog"> in index.html only holds a placeholder
and app.js fetches the <dl> on first open (it was ~110 KB, more than half
of the shell, shipped on every /passeio/ page too). Since v416 this is
STRICT: shell and pages are served cache-first from the version’s precache.index.html/app.js loads at boot goes in SHELL_ASSETS — ONE atomic
cache.addAll ({cache:'no-cache'}: fresh but 304 when unchanged), so a
404 there blocks everyone’s update (the old SW keeps control with its
complete set). Iframe pages and their libs go in PAGE_ASSETS
(best-effort). No ?v= anywhere — VERSION is the only version
mechanism; code is cache-first, so HTML and JS of two deploys never mix. A
query-versioned import (media-pipeline.js?api=N) falls back offline to
the precached copy without the query. Navigations to /, /index.html,
/passeio/<slug> (the app opens tours from the path — the SSR only reaches
crawlers and clients without a SW), /imagens/lista/…, /pessoas/<slug>,
/subir and the iframe pages come from the precache; ?format= bypasses
the SW. Unversioned caches (KEEP_CACHES; anything else is DELETED on
activate — don’t add a page-side Cache Storage): phidro-data-v1
(network-first racing a 3.5 s timer — the cached copy wins on weak 4G and
the network updates it in the background; data_graphs.ttl is SWR),
phidro-media-v1 (photo/clip thumb.jpg, refetched in CORS through the
302, FIFO 3000; large.jpg goes to the network and falls back to the
thumb offline; original.*, clip video/audio and tour_assets/ are never
cached), phidro-tiles-v1 (only layers with crossOrigin: '' on hosts that
ALWAYS send ACAO — osm, satellite, rmsampa, mtpi*, 1850; not sara1930, whose
WMS only sends it with an Origin), phidro-cdn-v1, phidro-graph-v1 (the
baked viário graph, cache-first, HEAD-revalidated ≤ 1×/24 h, one shared
in-flight GET) and the FGB block cache. Install copies data/graph/CDN
entries from the old versioned caches; opaque responses are never cached.
Updates: skipWaiting + clients.claim, registration.update() when the page
becomes visible (throttled 15 min), and watchSwUpdates shows “Nova versão
do amora disponível — ↻ Atualizar” (asks first if a form is busy/dirty).
Clients without a SW still get JS/CSS up to 4 h stale: Cloudflare’s Browser
Cache TTL rewrites the origin’s no-cache to max-age=14400 (“Respect
Existing Headers” there would fix it). routes.json with no cached copy and
no network shows “↻ Tentar de novo” (sidebar + banner, showActionToast)
and retries by itself on online.storage.blob_cache_control). Photo
thumb/large (content-addressed by pHash): 1 year immutable; original.*
keeps the short default (it carries the EXIF/GPS, and a privacy delete must
not stay servable from Google’s edge cache for a year); clips (keyed by the
SOURCE vHash — a re-upload with another trim rewrites them): 1 day. Applied
to new GCS writes and to local-mode Flask responses; objects written before
keep 1 h until a gcloud storage objects update --cache-control. The
/photos and /clips 302 itself is public, max-age=2592000, immutable
(30 days): moving buckets means keeping the old one readable that long.flask-compress (best-effort import; COMPRESS_STREAMS = True is
required or send_from_directory responses — app.js, style.css — go out
raw) and the string-built responses (/routes.json, /data/<ttl>) get
resp.add_etag() + make_conditional() via _conditional(). Without
the ETags, the SW’s network-first strategy re-downloads the full 2 MB
routes.json every visit instead of getting a 304. Don’t strip either
when touching those handlers. index.html also <link rel="preload">s
routes.json, and app.js fetches it without cache: 'no-cache' so
the two requests coalesce — keep them matched.ph:MediaSweep
(nó derivado pas:<slug>_sweep) registra o passe de coleta no grupo do zap —
quando foi feito, quantos arquivos vieram, quem compartilhou. Os três estados
são semanticamente distintos e não devem ser colapsados: nó ausente = o
passe nunca foi feito; ph:collectedFileCount 0 = foi feito e ninguém
compartilhou; n = n arquivos. Os contribuintes ficam como LITERAL (o
slug cru do nome do arquivo), não como IRI de pessoa — assim o passe nunca
trava esperando alguém ser cadastrado; a resolução é um join via
schema:alternateName. ph:mediaCount é a versão velha disso, está
owl:deprecated, e não se escreve mais nele. Ver docs/CAPTURA.md.rdfs:domain da CLASSE DO NÓ.
O validador roda com inference="rdfs": um rdfs:domain ph:Tour num
predicado do nó do passe tiparia pas:<slug>_sweep como ph:Tour, jogando-o
na ph:TourShape (que exige título e data) → Violation. Vale pra
ph:MediaSweep, ph:RouteReference, ph:SeriesEdition.ph:totalDuration é fallback, não fonte. O tempo total (saída→chegada)
é DERIVADO de ph:arrivedAt − ph:departedAt sempre que os dois existem; o
literal ph:totalDuration (xsd:duration) só é gravado quando NENHUM dos
dois horários existe (o form upload_tour.html deduz o horário que falta
quando há só um, e REMOVE o predicado no patch quando há os dois). Leitores
(censo) derivam primeiro e caem no literal. Não gravar os dois juntos._sync_tour_route
reusa os latlngs já em routes.json quando o ph:linkRoute não mudou, e
não reescreve o arquivo se a entrada ficou idêntica. Sem isso, escritas em
lote (87 passes, 87 gravações) viravam centenas de fetches de GPX no RWGPS +
centenas de rewrites de um JSON de 2 MB — e, com Object Versioning, cada
rewrite deixa uma geração noncurrent parada por 90 dias. Pra forçar a
rebusca: scripts/build-routes.py.PUBLIC_BASE_URL: sem ela, o backend grava no catálogo o host pelo qual
o cliente chegou (request.host_url) — e um backend de dev assa
http://localhost:8080/… num schema:image que depois sobe pra produção
(foi o que aconteceu com o PH/96). Quem auto-hospeda deve setá-la. As shapes
avisam e o painel de captura mostra a arte local como lacuna.ph: terms only for what is specific to
Pedal Hidrográfico.https://amora.pedalhidrografi.co/ via scripts/deploy-cloudrun.sh;
the same backend (backend/main.py) also runs locally for dev or
self-hosting — storage.py abstracts state via STORAGE_BACKEND=local
(filesystem) vs gcs (bucket). The old Raspberry Pi deploy was retired.
The old read-only static mirror at tiles.pedalhidrografi.co/rotas_app
(deployed by the now-removed scripts/deploy.sh) is retired.--workers 1 everywhere (Dockerfile, .plist).
The mutation lock (_state_lock) is per-process; with 2+
workers, concurrent uploads land in different processes and the second
read-modify-write of the TTL catalogs silently discards the first (lost
update). Concurrency comes from threads; Cloud Run scales by instances.
Don’t “tune” the worker count up.uploads.ttl, tours.ttl, routes.json — are mutated both
locally (build scripts, edits) and server-side (uploads, Tour CRUD via the
bucket). data_graphs.ttl is pushed alongside them but is NOT itself
dual-writer: the backend never mutates it (it’s a static VoID shim, see
Architecture above), so it only ever changes if you hand-edit it locally.
web/saved_routes.json (the route-editor’s save library, /save-route /
/delete-route) is also server-mutated bucket-first state but currently
sits outside this guarded sync — there’s no local↔bucket round-trip for it
yet. scripts/sync-guard.sh
(sourced by deploy-cloudrun.sh and pull-cloudrun.sh) stashes the MD5
of the last successful sync in .sync-state/ (gitignored, per-machine)
and refuses any copy whose destination changed since that baseline AND
differs from the source — exit 3 with reconciliation instructions.
--force overrides (and establishes the baseline on first use on a new
machine). Don’t bypass the guard with raw gcloud storage cp; photos/
and clips/ are content-addressed and additive, so they stay unguarded.upload_*.html forms → POST); routes.json is server-owned (incremental
sync on Tour CRUD). The only local catalog-writers left are the batch/
recovery scripts: build-clips.py (writes uploads.ttl) and
build-routes.py (full routes.json rebuild). Treat them as round-trip:
pull-cloudrun.sh → run → deploy-cloudrun.sh --state[-only] (sync-guarded).
Don’t hand-edit tours.ttl/uploads.ttl — edit tours via
upload_tour.html?id= (mode=patch) or backfill_tours.html; if you must
hand-edit, pull first and push immediately through the guarded scripts.deploy-cloudrun.sh, mirroring the CORS block). Every server write
to a state file keeps the prior generation; noncurrent versions expire after
90 days (daysSinceNoncurrentTime — never age, which would delete live
objects). This is the recovery net for a clobber / bad purge / lost update.
Browse + recover with scripts/state-history.sh list|diff|restore <file>;
restore is non-destructive (writes a new current generation) — follow it
with POST /reload so the backend re-reads. Local (STORAGE_BACKEND=local)
has no equivalent; history there is just git for the tracked TTLs.bucket.get_blob(key)
rather than bucket.blob(key) + download_as_text() — the bare-blob form
produced silently-stale content in Cloud Run despite the bucket having
one current generation. See GCSStateStore.read_text in
backend/storage.py for the fix..gcloudignore /
.dockerignore exclude web/photos/ and web/clips/ entirely (not
just raw/). The runtime handlers /photos/<path> and /clips/<path>
redirect to the bucket’s public URL in gcs mode (302 → much faster
than streaming through Flask). To populate the bucket with local
build-clips.py outputs and locally-collected uploads, run
scripts/deploy-cloudrun.sh --state-only.web/: load it in a browser (or the existing dev server) — the
browser surfaces syntax errors immediately. No standalone JS tooling here.web/sw.js VERSION if any file under web/ changed.python -m py_compile backend/main.pyrdflib after editing *.ttl.python scripts/build-routes.py — full rebuild of web/routes.json by
reading web/data/tours.ttl (the Tour catalog) and fetching each
referenced GPX from RideWithGPS. Requires python-dotenv plus
RWGPS_API_KEY / RWGPS_AUTH_TOKEN in .env. Not the normal path —
the backend keeps routes.json incrementally on every Tour CRUD; use this
only for bake/recovery, then push via deploy-cloudrun.sh --state.python scripts/build-clips.py — re-encode anything in web/clips/raw/
to 360p/720p mp4 + .m4a audio + thumbnail, and upsert each as a
ph:Video in web/data/uploads.ttl (associates with nearest tour
within ±12 h). See “Clips workflow” above. Requires ffmpeg + exiftool.main deploys to production (amora.pedalhidrografi.co):
.github/workflows/deploy.yml runs deploy-cloudrun.sh --no-catalog-push
via keyless WIF (code + shapes/ontology only — never catalogs or state).
Pushes touching only docs/, research/, eink/, *.md, .vscode/ or
.github/ skip it; the Actions tab has a manual “Run workflow” button. So
don’t push half-done web/ work to main — use a branch. The manual script
below is still the path for --state/--state-only syncs.bash scripts/deploy-cloudrun.sh — build + deploy backend to Cloud Run
(project pedal-hidrografico, region southamerica-east1, service
phidro, bucket phidro-state). Reads RWGPS_API_KEY/RWGPS_AUTH_TOKEN
from the local .env and injects them as service env vars (the .env
itself never enters the build context — it’s in .gcloudignore). Flags:
--state build + deploy + sync mutable state (uploads.ttl,
data_graphs.ttl, routes.json, photos/, clips/, tour_assets/)
to the bucket--state-only just sync mutable state, skip rebuild--mirror make the bucket an exact mirror of local (deletes objects
that no longer exist locally; pairs with --state/--state-only)--force override the anti-clobber guard (see Conventions)--dry-run preview without executingpip install -r backend/requirements.txt && python backend/main.py
(defaults to port 8000; override with PORT=…). See backend/README.md.web/data/uploads.ttl and web/photos/<phash>/ are runtime artifacts
of the backend — gitignore or commit per your deploy strategy. The CDN
mirror shows no photos until those files exist at the destination.web/clips/raw/ holds source videos (large; ~800 MB total).
Probably want gitignored. The build artifacts (*.360p.mp4,
*.720p.mp4, audio/*.m4a, *.thumb.jpg) are smaller and can be
committed if you want the static mirror to ship clips, or generated in
CI. The catalog of triples lives in web/data/uploads.ttl (single
source of truth for both images and videos). For Cloud Run, all of
web/clips/ and web/photos/ is excluded from the container and lives
in the phidro-state bucket — push local outputs with
scripts/deploy-cloudrun.sh --state-only.web/upload_videos.html is a permanent redirect stub pointing at
upload_images.html (which now handles both media types). Safe to
delete once you’re sure no bookmark uses the old URL.git status and commit with meaningful messages.