TEST
body{margin:0}[hidden]{display:none!important}Rallye Casinos do Algarve 2026
/* Layout: timing-board header, horizontal stage strip, classification table with a side rail for "em pista" */
:root{
--bg:#eef1f3; --surface:#ffffff; --ink:#14191f; --muted:#5d6873; --line:#d6dce1; --soft:#e5eaee;
--accent:#0b57c9; --accent-ink:#ffffff; --best:#7a3cf0; --live:#12834a; --warn:#b4610a; --alert:#c62828;
--f-display:"Saira Condensed","Arial Narrow",system-ui,sans-serif;
--f-body:"Source Sans 3",system-ui,-apple-system,"Segoe UI",sans-serif;
--f-data:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
--bg:#0f1316; --surface:#171c21; --ink:#e8ecef; --muted:#97a3ae; --line:#29323a; --soft:#1e252b;
--accent:#5b95ff; --accent-ink:#0b1220; --best:#ad8bff; --live:#3fcf82; --warn:#f0a443; --alert:#e5484d; color-scheme:dark}}
:root[data-theme="dark"]{
--bg:#0f1316; --surface:#171c21; --ink:#e8ecef; --muted:#97a3ae; --line:#29323a; --soft:#1e252b;
--accent:#5b95ff; --accent-ink:#0b1220; --best:#ad8bff; --live:#3fcf82; --warn:#f0a443; --alert:#e5484d; color-scheme:dark}
*{box-sizing:border-box}
body{background:var(--bg);color:var(--ink);font-family:var(--f-body);font-size:15px;line-height:1.45}
.wrap{max-width:1180px;margin:0 auto;padding-inline:16px;padding-block:20px 48px}
header.top{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:12px 24px;padding-bottom:16px;border-bottom:2px solid var(--ink)}
.eyebrow{font-family:var(--f-display);font-weight:600;letter-spacing:.12em;text-transform:uppercase;font-size:13px;color:var(--muted)}
h1{font-family:var(--f-display);font-weight:800;font-size:clamp(30px,5vw,46px);line-height:.95;margin:4px 0 0;text-transform:uppercase;letter-spacing:.01em;text-wrap:balance}
.status{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted);flex-wrap:wrap}
.dot{width:9px;height:9px;border-radius:50%;background:var(--muted);flex:none}
.dot.live{background:var(--live);box-shadow:0 0 0 0 var(--live);animation:pulse 2s infinite}
@keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--live) 60%,transparent)}70%{box-shadow:0 0 0 8px transparent}100%{box-shadow:0 0 0 0 transparent}}
@media (prefers-reduced-motion:reduce){.dot.live{animation:none}}
.status b{color:var(--ink);font-weight:600}
/* stage strip */
.strip{display:flex;gap:6px;overflow-x:auto;padding-block:14px 10px;scrollbar-width:thin}
.day{display:flex;gap:6px;align-items:stretch}
.daylab{writing-mode:vertical-rl;transform:rotate(180deg);font-family:var(--f-display);font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);padding:2px 0}
.day + .day{margin-left:10px;padding-left:10px;border-left:1px solid var(--line)}
.st{appearance:none;border:1px solid var(--line);background:var(--surface);color:var(--ink);border-radius:6px;padding:7px 10px 8px;min-width:92px;text-align:left;cursor:pointer;display:grid;gap:1px;font:inherit}
.st:hover{border-color:var(--muted)}
.st:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.st .code{font-family:var(--f-display);font-weight:800;font-size:20px;line-height:1}
.st .nm{font-size:11.5px;color:var(--muted);white-space:nowrap;max-width:130px;overflow:hidden;text-overflow:ellipsis}
.st .meta{font-family:var(--f-data);font-size:11px;color:var(--muted)}
.st[aria-pressed="true"]{background:var(--ink);color:var(--bg);border-color:var(--ink)}
.st[aria-pressed="true"] .nm,.st[aria-pressed="true"] .meta{color:color-mix(in srgb,var(--bg) 75%,transparent)}
.st .flag{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:5px;vertical-align:middle;background:var(--line)}
.st .flag.s2{background:var(--ink)} .st[aria-pressed="true"] .flag.s2{background:var(--bg)}
.st .flag.s1{background:var(--live)}
/* main */
.grid{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:20px;margin-top:8px}
@media (max-width:900px){.grid{grid-template-columns:minmax(0,1fr)}}
.stagehead{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 16px;margin-bottom:10px}
.stagehead h2{font-family:var(--f-display);font-weight:800;font-size:30px;line-height:1;margin:0;text-transform:uppercase}
.stagehead .facts{font-family:var(--f-data);font-size:12.5px;color:var(--muted);display:flex;gap:14px;flex-wrap:wrap}
.pill{font-family:var(--f-display);font-weight:700;letter-spacing:.08em;text-transform:uppercase;font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid currentColor}
.pill.s0{color:var(--muted)} .pill.s1{color:var(--live)} .pill.s2{color:var(--ink)}
.controls{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;margin-bottom:10px}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:var(--surface)}
.seg button{appearance:none;border:0;background:transparent;color:var(--ink);font:600 13px var(--f-body);padding:6px 12px;cursor:pointer}
.seg button[aria-pressed="true"]{background:var(--accent);color:var(--accent-ink)}
.seg button:disabled{color:var(--muted);cursor:not-allowed}
.seg button:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
label.sel{display:inline-flex;gap:6px;align-items:center;font-size:13px;color:var(--muted)}
select{font:inherit;font-size:13px;color:var(--ink);background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:5px 8px}
.tablebox{background:var(--surface);border:1px solid var(--line);border-radius:8px;overflow-x:auto}
table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
th{font-family:var(--f-display);font-weight:700;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);text-align:left;padding:9px 10px;border-bottom:1px solid var(--line);white-space:nowrap;background:var(--surface);position:sticky;top:0}
td{padding:8px 10px;border-bottom:1px solid var(--soft);vertical-align:middle}
tr:last-child td{border-bottom:0}
td.num,th.num{text-align:right}
.pos{font-family:var(--f-display);font-weight:800;font-size:18px;width:44px}
.no{font-family:var(--f-data);font-weight:600;font-size:12.5px;display:inline-block;min-width:34px;text-align:center;padding:2px 5px;border:1.5px solid var(--ink);border-radius:4px;background:var(--surface)}
.crew{min-width:190px}
.crew .p{font-weight:700}
.crew .c{color:var(--muted);font-size:13px}
.car{font-size:12.5px;color:var(--muted);white-space:nowrap}
.cls{font-family:var(--f-data);font-size:12px;white-space:nowrap}
.t{font-family:var(--f-data);font-weight:600;white-space:nowrap}
.gap{font-family:var(--f-data);font-size:13px;color:var(--muted);white-space:nowrap}
.best .t{color:var(--best)}
.runs{font-family:var(--f-data);font-size:12px;color:var(--muted);white-space:nowrap}
.runs .b{color:var(--ink);font-weight:600}
.pen{color:var(--warn);font-family:var(--f-data);font-size:12.5px}
@media (max-width:640px){.hide-sm{display:none}}
.empty{padding:28px 18px;color:var(--muted)}
.empty b{color:var(--ink);display:block;font-size:16px;margin-bottom:4px}
/* side rail */
aside{display:flex;flex-direction:column;gap:16px;min-width:0}
.panel{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:14px}
.panel h3{font-family:var(--f-display);font-weight:800;font-size:16px;letter-spacing:.06em;text-transform:uppercase;margin:0 0 10px;display:flex;align-items:center;gap:8px}
.onstage{display:flex;flex-direction:column;gap:8px}
.car-on{display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center}
.car-on .who{min-width:0}
.car-on .who div{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.car-on .who .sub{font-size:12px;color:var(--muted)}
.elapsed{font-family:var(--f-data);font-weight:600;color:var(--live)}
.next .elapsed{color:var(--muted);font-weight:500}
.muted{color:var(--muted);font-size:13px}
.kv{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;font-size:13px}
.kv dt{color:var(--muted)} .kv dd{margin:0;font-family:var(--f-data);text-align:right}
/* view tabs */
.tabs{display:flex;gap:4px;margin-top:14px;border-bottom:1px solid var(--line)}
.tab{appearance:none;border:0;background:transparent;color:var(--muted);font:700 15px var(--f-display);letter-spacing:.08em;text-transform:uppercase;padding:8px 14px 9px;cursor:pointer;border-bottom:3px solid transparent;margin-bottom:-1px}
.tab[aria-selected="true"]{color:var(--ink);border-bottom-color:var(--accent)}
.tab:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
.tab .badge{display:inline-block;min-width:18px;padding:0 5px;margin-left:6px;border-radius:9px;background:var(--alert);color:#fff;font:600 11px/18px var(--f-data);text-align:center;letter-spacing:0}
/* live map */
.mapgrid{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:16px;margin-top:16px}
@media (max-width:900px){.mapgrid{grid-template-columns:minmax(0,1fr)}}
#map{height:min(74vh,720px);min-height:380px;border-radius:8px;border:1px solid var(--line);background:var(--soft)}
@media (max-width:640px){#map{height:62vh}}
.maprail{display:flex;flex-direction:column;gap:12px;min-width:0}
.maprail .panel{padding:12px}
.ctl{display:grid;gap:10px}
.ctl label{display:grid;gap:4px;font-size:12.5px;color:var(--muted)}
.ctl select{width:100%}
.btn{appearance:none;border:1px solid var(--line);background:var(--surface);color:var(--ink);font:600 13px var(--f-body);padding:6px 10px;border-radius:6px;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:6px}
.btn:hover{border-color:var(--muted)}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.btn.on{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
.btn.sm{padding:3px 8px;font-size:12px}
.alerts{display:flex;flex-direction:column;gap:8px}
.al{border:1px solid var(--alert);border-left-width:4px;border-radius:6px;padding:8px 10px;display:grid;gap:6px}
.al.warn{border-color:var(--warn)}
.al.seen{opacity:.6}
.al .top{display:grid;grid-template-columns:auto 1fr auto;gap:8px;align-items:center}
.al .what{font-family:var(--f-data);font-weight:600;color:var(--alert);white-space:nowrap}
.al.warn .what{color:var(--warn)}
.al .acts{display:flex;gap:6px;flex-wrap:wrap}
.carlist{display:flex;flex-direction:column;gap:6px;max-height:340px;overflow:auto}
.carlist .car-on{cursor:pointer}
.kmbar{height:4px;background:var(--soft);border-radius:2px;overflow:hidden;margin-top:3px}
.kmbar i{display:block;height:100%;background:var(--accent)}
.alertbar{position:sticky;top:env(safe-area-inset-top,0px);z-index:1000;background:var(--alert);color:#fff;padding:9px 14px;border-radius:0 0 8px 8px;font-weight:600;display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap}
.alertbar button{appearance:none;border:1px solid #fff;background:transparent;color:#fff;font:600 12.5px var(--f-body);padding:4px 10px;border-radius:5px;cursor:pointer}
.mk{font:600 11px/1 var(--f-data);color:#fff;background:#14191f;border:2px solid #3fcf82;border-radius:4px;padding:2px 4px;white-space:nowrap;transform:translate(-50%,-50%);display:inline-block;box-shadow:0 1px 3px rgba(0,0,0,.4)}
.mk.stop{border-color:#f0a443}
.mk.alert{background:#d32f2f;border-color:#fff;animation:mkp 1.2s infinite}
.mk.stale{border-color:#97a3ae;opacity:.7}
.mk.off{opacity:.55;border-color:#97a3ae}
.mk.org{background:#5d6873;border-color:#5d6873;font-size:9.5px;opacity:.85}
@keyframes mkp{50%{transform:translate(-50%,-50%) scale(1.25)}}
@media (prefers-reduced-motion:reduce){.mk.alert{animation:none}}
.leaflet-div-icon.mkwrap{background:none;border:0}
.mapnote{padding:28px 18px}
footer{margin-top:28px;font-size:12.5px;color:var(--muted);border-top:1px solid var(--line);padding-top:12px}
footer a{color:var(--accent)}
const SNAPSHOT = {"it": 1563, "pa": [[84112, "3", "Armindo ARAÚJO", "Luís RAMALHO", "SKODA FABIA RS RALLY2", "RC2", "CPR||CPMst", 1, 0, "", "The Racing Factory", "PRT"], [84113, "5", "Ricardo FILIPE", "Filipe CARVALHO", "SKODA FABIA R5", "RC2", "CPR||CPMst", 1, 0, "", "Arc Sport", "PRT"], [84114, "6", "José Pedro FONTES", "Inês PONTE", "LANCIA YPSILON RALLY2", "RC2", "CPR||CPMstR", 1, 0, "", "Sports & You", "PRT"], [84115, "8", "Helder MIRANDA", "Mariana MACHADO", "PEUGEOT 208 RALLY4", "RC4", "CPR||CPR2RM||PRCP/I", 1, 0, "", "", "PRT"], [84116, "9", "Paulo NETO", "Carlos MAGALHÃES", "SKODA FABIA RS RALLY2", "RC2", "CPR||CPMstR||CPMst", 1, 0, "", "Arc Sport", "PRT"], [84117, "13", "Henrique MONIZ", "Jorge DINIZ", "SKODA FABIA RS RALLY2", "RC2", "CPR", 1, 0, "", "", "PRT"], [84118, "14", "João RODRIGUES", "Bruno CARVALHO", "PEUGEOT 208 RALLY4", "RC4", "CPR||CPR2RM||PRCP/I", 1, 0, "", "", "PRT"], [84119, "15", "Pedro ALMEIDA", "António COSTA", "TOYOTA GR YARIS RALLY2", "RC2", "CPR", 1, 0, "", "Sports & You", "PRT"], [84120, "16", "Pedro GASTÃO", "Marco MACEDO", "PEUGEOT 208 RALLY4", "RC4", "CPR||CPJR||CPR2RM", 1, 0, "", "", "PRT"], [84121, "18", "Pedro CÂMARA", "João CÂMARA", "PEUGEOT 208 RALLY4", "RC4", "CPR||CPJR||CPR2RM||PRCP/I", 1, 0, "", "The Racing Factory", "PRT"], [84123, "20", "Luís CIDADE", "Jorge CARVALHO", "TOYOTA YARIS GR RALLY2", "RC2", null, 1, 0, "", "Racing4you", "PRT"], [84124, "21", "Afonso SANTOS", "Mafalda SANTOS", "PEUGEOT 208 RALLY4", "RC4", "CPR||CPJR||CPR2RM||PRCP/I||CPJRN", 1, 0, "", "", "PRT"], [84125, "22", "Diogo SALVI", "Hugo MAGALHÃES", "SKODA FABIA RS RALLY2", "RC2", "CPR||CPMstR", 1, 0, null, "Racing4you", "PRT"], [84126, "23", "Pedro MEIRELES", "Mário CASTRO", "SKODA FABIA RS RALLY2", "RC2", "CPR||CPMstR||CPMst", 1, 0, "", "Racing4you", "PRT"], [84127, "24", "Ricardo TEODÓSIO", "José TEIXEIRA", "CITROEN C3 RALLY2", "RC2", "CPR||CPMstR||CPMst", 1, 0, "", "", "PRT"], [84128, "26", "Heindrichs", "Schmitz", "OPEL CORSA RALLY4", "RC4", null, 1, 0, null, "", "BEL"], [84129, "27", "Gonçalo HENRIQUES", "Gonçalo CUNHA", "HYUNDAI I20 RALLY2", "RC2", "CPR", 1, 0, "", "Sports & You", "PRT"], [84130, "28", "Afonso SEIXAS", "Francisco PONTE", "RENAULT CLIO RALLY 4/5", "RC5", "CPR||CPJR||CPR2RM", 1, 0, "", "", "PRT"], [84131, "29", "Carlos MARREIROS", "Ricardo BARRETO", "RENAULT CLIO RALLY 4/5", "RC5", "CPR||CPJR||CPR2RM||CCLIOR5", 1, 0, null, "", "PRT"], [84132, "30", "Maximilian LICHTENEGGER", "Bernhard ETTEL", "RENAULT CLIO RALLY3", "RC3", null, 1, 0, null, "", "AUT"], [84133, "31", "Guilherme MEIRELES", "Pedro ALVES", "SKODA FABIA RALLY2 EVO", "RC2", "CPR", 1, 0, "", "Racing4you", "PRT"], [84136, "35", "Carlos RODRIGUEZ", "Gema SANCHEZ", "PEUGEOT 208 RALLY4", "RC4", "PRCP/I", 1, 0, null, "", "ESP"], [84137, "37", "Tiago PEREIRA", "Fernando PEREIRA", "RENAULT CLIO RALLY 4/5", "RC5", "CPR||CPR2RM||CCLIOR5||CPMst", 1, 0, "", "", "PRT"], [84138, "38", "José PAULA", "Paulo LOPES", "CITROEN C3 RALLY2", "RC2", null, 1, 0, "", "", "PRT"], [84139, "39", "Pedro SILVA", "Valter CARDOSO", "LANCIA YPSILON RALLY4", "RC4", "CPR||CPR2RM", 1, 0, "", "Vision Motorsport", "PRT"], [84140, "40", "Ernesto CUNHA", "António COELHO", "SKODA FABIA RS RALLY2", "RC2", null, 1, 0, null, "Arc Sport", "PRT"], [84142, "44", "Luís CAETANO", "David MONTEIRO", "RENAULT CLIO RALLY 4/5", "RC5", "CPR||CPJR||CPR2RM||CCLIOR5||CPJRN", 1, 0, "", "Wintech Competição", "PRT"], [84143, "45", "Luis Miguel REGO", "José JANELA", "SKODA FABIA RS RALLY2", "RC2", null, 1, 0, "", "Regojr Garage", "PRT"], [84144, "46", "Cristiano GIL", "Rodrigo ASSUNÇÃO", "RENAULT CLIO RALLY 4/5", "RC4", "CPR||CPR2RM||CCLIOR5", 1, 0, "", "Wintech Competição", "PRT"], [84145, "47", "Miguel CARVALHO", "António REIS", "RENAULT CLIO RALLY 4/5", "RC5", "CCLIOR5", 1, 0, "", "", "PRT"], [84146, "48", "Henrique SILVA", "Fábio RIBEIRO", "RENAULT CLIO RALLY 4/5", "RC5", "CCLIOR5", 1, 0, "", "", "PRT"], [84147, "49", "Gonçalo REGO", "Pedro CASTRO", "PEUGEOT 208 RALLY4", "RC4", "PRCP/I", 1, 0, "", "", "PRT"], [84148, "50", "Joni GONÇALVES", "Vítor OLIVEIRA", "PEUGEOT 208 RALLY4", "RC4", "PRCP/I", 1, 0, null, "", "PRT"], [84149, "51", "Filipa TINTIM", "Mariana TINTIM", "RENAULT CLIO RALLY 4/5", "RC5", "CCLIOR5||CSRF", 1, 0, "", "", "PRT"], [84150, "55", "Hugo LOPES", "Magda OLIVEIRA", "HYUNDAI I20 RALLY2", "RC2", "CPR", 1, 0, null, "Sports & You", "PRT"], [84151, "56", "Ricardo SOUSA", "Luís MARQUES", "SKODA FABIA RS RALLY2", "RC2", null, 1, 0, "", "", "PRT"], [84152, "60", "Bryn JONES", "Max FREEMAN", "PEUGEOT 208 RALLY4", "RC4", "CPR||CPJR||CPR2RM||PRCP/I", 1, 0, null, "", "NZL"], [84153, "67", "Rafael RÊGO", "Nuno Mota RIBEIRO", "TOYOTA GR YARIS RALLY2", "RC2", "CPR", 1, 0, "", "Sports & You", "PRT"], [84154, "69", "Rúben RODRIGUES", "Rui RAIMUNDO", "TOYOTA GR YARIS RALLY2", "RC2", "CPR", 1, 0, "", "Arc Sport", "PRT"], [84155, "81", "Filipe MADUREIRA", "Cristina SILVA", "NISSAN MICRA 1.3 S", "A5", "TPCR", 1, 0, "", "", "PRT"], [84156, "101", "Nelson TRINDADE", "Raquel GRAÇA", "SKODA FABIA N5", "17", "CProR||CSR", 1, 0, "", "", "PRT"], [84157, "102", "Carlos MARTINS", "Daniel AMARAL", "FORD FIESTA R5", "7", "CProR||CSR||CR5", 1, 0, "", "", "PRT"], [84158, "103", "Fernando TEOTÓNIO", "Luís MORGADINHO", "MITSUBISHI LANCER EVO IX", "14", "CProR||CSR", 1, 0, "", "", "PRT"], [84159, "104", "Henrique RODRIGUES", "Daniel RODRIGUES", "SKODA FABIA N5", "17", "CProR||CSR", 1, 0, "", "", "PRT"], [84161, "106", "José MERCEANO", "Francisco PEREIRA", "MITSUBISHI EVO IV", "14", "CProR||CSR", 1, 0, "", "", "PRT"], [84162, "109", "Nuno PINTO", "Pedro CONTENTE", "MITSUBISHI LANCER EVO VIII", "14", "CProR||CSR", 1, 0, "", "", "PRT"], [84163, "110", "Nuno VENÂNCIO", "Fernando D'ALMEIDA", "MITSUBISHI LANCER EVOLUTION VII", "14", "CProR||CSR", 1, 0, "", "", "PRT"], [84164, "111", "João COLAÇO", "Alexandre RODRIGUES", "MITSUBISHI LANCER EVOLUTION X", "14", "CProR||CSR", 1, 0, "", "", "PRT"], [84165, "112", "Joaquim SALERO", "Gonçalo CORREIA", "MITSUBISHI LANCER EVOLUTION VI", "14", "CProR||CSR", 1, 0, "", "", "PRT"], [84166, "114", "José Carlos PATÉ", "Hermes BANZA", "MITSUBISHI LANCER EVO VII", "14", "CProR||CSR", 1, 0, "", "", "PRT"], [84167, "115", "Duarte MONTEIRO", "Joana MONTEIRO", "RENAULT TWINGO RS", "2", "CProR||CSR||CSR2RM||CProR2RM", 1, 0, null, "", "PRT"], [84168, "116", "João ARAÚJO", "Luis ARAUJO", "FORD FIESTA N5", "17", "CProR||CSR", 1, 0, "", "", "PRT"], [84169, "117", "Paulo CAVACO", "Diogo AUGUSTO", "MITSUBISHI LANCER EVO VIII", "14", "CProR||CSR", 1, 0, "", "", "PRT"], [84170, "151", "José Viana MARTINS", "Alexandre LOPES", "PEUGEOT 208 VTI (R2B)", "2", "CProR||CSR||CSR2RM||CProR2RM", 1, 0, "", "", "PRT"], [84171, "152", "Paulo ANSELMO", "André SILVA", "BMW E36", "11", "CProR||CSR||CSR2RM||CProR2RM", 1, 0, null, "", "PRT"], [84172, "153", "Miguel GONÇALVES", "David COELHO", "RENAULT CLIO RALLY 4/5", "4", "CProR||CSR||CSR2RM||CProR2RM", 1, 0, "", "", "PRT"], [84173, "154", "Vasco TINTIM", "Carlos PEREIRA", "RENAULT CLIO RALLY 4/5", "4", "CProR||CSR||CSR2RM||CProR2RM", 1, 0, "", "", "PRT"], [84174, "155", "Gabriel MARREIROS", "Rui SERRA", "CITROËN SAXO VTS", "9", "CProR||CSR||CSR2RM||CProR2RM", 1, 0, "", "", "PRT"], [84175, "156", "Rudi CAVACO", "Inês MEALHA", "SUZUKI SWIFT", "2", "CProR||CSR||CSR2RM||CProR2RM", 1, 0, "", "", "PRT"], [84176, "157", "Paulo ALMEIDA", "José MARTINS", "PEUGEOT 206 XS", "9", "CProR||CSR||CSR2RM||CProR2RM", 1, 0, "", "", "PRT"], [84177, "158", "André GUERREIRO", "Pedro JÚLIO", "PEUGEOT 206 GTI", "10", "CProR||CSR||CSR2RM||CProR2RM", 1, 0, "", "", "PRT"], [84178, "159", "Yulian TEODÓSIO", "Florival NETO", "PEUGEOT 106", "8", "CProR||CSR||CSR2RM||CProR2RM", 1, 0, "", "", "PRT"], [84179, "160", "Pedro DUARTE", "Feliciano JÚLIO", "PEUGEOT 106", "9", "CProR||CSR||CSR2RM||CProR2RM", 1, 0, "", "", "PRT"]], "specials": [[10211, "SHK", "SHAKEDOWN", 3730, 2, 1790924400, 0, 1, 6], [10212, "SS1", "LAGOS", 7480, 0, 1790954280, 1, 1, 1], [10213, "SS2", "BUDENS", 10690, 0, 1790955960, 1, 1, 1], [10214, "CS", "CITY STAGE DE LAGOS", 1020, 0, 1790971200, 0, 1, 1], [10215, "SS3", "MESSINES 1", 24300, 0, 1791016080, 1, 1, 1], [10216, "SS4", "SANTANA DA SERRA 1", 15080, 0, 1791019260, 1, 1, 1], [10217, "SS5", "SAO MARCOS DA SERRA 1", 11270, 0, 1791022140, 1, 1, 1], [10218, "SS6", "MESSINES 2", 24300, 0, 1791032220, 1, 1, 1], [10219, "SS7", "SANTANA DA SERRA 2", 15080, 0, 1791035400, 1, 1, 1], [10220, "SS8", "SAO MARCOS DA SERRA 2 - POWER STAGE", 11270, 0, 1791038280, 1, 3, 1]], "cls": {"10211": {"s": [[84119, 148.8, 0, 148.8, 2], [84112, 149.7, 0, 149.7, 2], [84153, 149.7, 0, 149.7, 2], [84123, 154.3, 0, 154.3, 1], [84143, 154.6, 0, 154.6, 2], [84132, 156.7, 0, 156.7, 2], [84114, 157.9, 0, 157.9, 2], [84151, 157.9, 0, 157.9, 1], [84140, 158.9, 0, 158.9, 1], [84117, 159.3, 0, 159.3, 1], [84128, 165.4, 0, 165.4, 2], [84129, 166, 0, 166, 1], [84118, 166, 0, 166, 2], [84116, 166.6, 0, 166.6, 1], [84133, 167.2, 0, 167.2, 1], [84158, 168.8, 0, 168.8, 3], [84148, 169.2, 0, 169.2, 2], [84124, 169.6, 0, 169.6, 2], [84150, 171.1, 0, 171.1, 1], [84115, 172.6, 0, 172.6, 1], [84120, 173.6, 0, 173.6, 2], [84139, 177, 0, 177, 2], [84152, 177.8, 0, 177.8, 2], [84147, 180.5, 0, 180.5, 2], [84136, 180.9, 0, 180.9, 2], [84142, 182.1, 0, 182.1, 2], [84156, 182.4, 0, 182.4, 2], [84159, 183.5, 0, 183.5, 2], [84137, 184.6, 0, 184.6, 1], [84126, 185.4, 0, 185.4, 1], [84144, 185.8, 0, 185.8, 2], [84131, 186.7, 0, 186.7, 2], [84113, 189.5, 0, 189.5, 1], [84127, 191.3, 0, 191.3, 1], [84130, 191.5, 0, 191.5, 2], [84164, 193.4, 0, 193.4, 2], [84146, 200.9, 0, 200.9, 1], [84167, 217.2, 0, 217.2, 1], [84149, 217.7, 0, 217.7, 2], [84172, 222.7, 0, 222.7, 1], [84174, 232.2, 0, 232.2, 1], [84154, 245.4, 0, 245.4, 1]], "a": []}}, "chr": {"10211": [[84112, 1, 1790925000, 212, 1790925212.064], [84112, 2, 1790931240, 149.7, 1790931389.754], [84113, 1, 1790926200, 189.5, 1790926389.546], [84114, 1, 1790924400, 157.9, 1790924557.937], [84114, 2, 1790928060, 158.7, 1790928218.731], [84115, 1, 1790934000, 172.6, 1790934172.617], [84116, 1, 1790930820, 166.6, 1790930986.663], [84117, 1, 1790928480, 159.3, 1790928639.382], [84118, 1, 1790930340, 168.3, 1790930508.34], [84118, 2, 1790932200, 166, 1790932366.075], [84119, 1, 1790924580, 176.1, 1790924756.129], [84119, 2, 1790929680, 148.8, 1790929828.884], [84120, 1, 1790934720, 174.4, 1790934894.443], [84120, 2, 1790938200, 173.6, 1790938373.686], [84123, 1, 1790929260, 154.3, 1790929414.346], [84124, 1, 1790933460, 175.2, 1790933635.287], [84124, 2, 1790937180, 169.6, 1790937349.651], [84126, 1, 1790925600, 185.4, 1790925785.456], [84127, 1, 1790926800, 191.3, 1790926991.312], [84128, 1, 1790931420, 172.1, 1790931592.11], [84128, 2, 1790935980, 165.4, 1790936145.475], [84129, 1, 1790927220, 166, 1790927386.058], [84130, 1, 1790932380, 204.6, 1790932584.65], [84130, 2, 1790936280, 191.5, 1790936471.501], [84131, 1, 1790935500, 186.7, 1790935686.71], [84131, 2, 1790938740, 187.8, 1790938927.858], [84132, 1, 1790931780, 157.9, 1790931937.94], [84132, 2, 1790935380, 156.7, 1790935536.711], [84133, 1, 1790927640, 167.2, 1790927807.259], [84136, 1, 1790932920, 190.5, 1790933110.5], [84136, 2, 1790937000, 180.9, 1790937180.914], [84137, 1, 1790933280, 184.6, 1790933464.687], [84139, 1, 1790934180, 187.7, 1790934367.734], [84139, 2, 1790938020, 177, 1790938197.031], [84140, 1, 1790930580, 158.9, 1790930738.906], [84142, 1, 1790932740, 186, 1790932926.08], [84142, 2, 1790936820, 182.1, 1790937002.132], [84143, 1, 1790925900, 176.7, 1790926076.792], [84143, 2, 1790931060, 154.6, 1790931214.648], [84144, 1, 1790933640, 187.2, 1790933827.214], [84144, 2, 1790937660, 185.8, 1790937845.808], [84146, 1, 1790934540, 200.9, 1790934740.937], [84147, 1, 1790935620, 181.9, 1790935801.926], [84147, 2, 1790938560, 180.5, 1790938740.575], [84148, 1, 1790933100, 172.5, 1790933272.557], [84148, 2, 1790937300, 169.2, 1790937469.286], [84149, 1, 1790935140, 217.7, 1790935357.768], [84149, 2, 1790938380, 218, 1790938598.002], [84150, 1, 1790926500, 171.1, 1790926671.193], [84151, 1, 1790928900, 157.9, 1790929057.93], [84152, 1, 1790932560, 180.6, 1790932740.686], [84152, 2, 1790936460, 177.8, 1790936637.893], [84153, 1, 1790924760, 213.1, 1790924973.187], [84153, 2, 1790930040, 149.7, 1790930189.791], [84154, 1, 1790925300, 245.4, 1790925545.466], [84156, 1, 1790931960, 186.6, 1790932146.678], [84156, 2, 1790936100, 182.4, 1790936282.462], [84158, 1, 1790934360, 176.9, 1790934536.927], [84158, 2, 1790937840, 173, 1790938013.067], [84158, 3, 1790938860, 168.8, 1790939028.843], [84159, 1, 1790931600, 185.5, 1790931785.599], [84159, 2, null, 0, 1790938013.273], [84159, 3, 1790934960, 183.5, 1790935143.568], [84159, 4, null, 0, 1790939029.071], [84164, 1, 1790933820, 193.4, 1790934013.407], [84164, 2, 1790937480, 194.1, 1790937674.101], [84167, 1, 1790935860, 217.2, 1790936077.285], [84172, 1, 1790936640, 222.7, 1790936862.7], [84174, 1, 1790935740, 232.2, 1790935972.215]]}, "ts": 1790952792905};
const API = 'https://rest3.anube.es/rallyrest/timing/api/';
const RALLY = 391;
const TZ = 'Europe/Lisbon';
let D = SNAPSHOT, live = false, sel = null, view = 'stage', fChamp = '', fClass = '', lastOk = SNAPSHOT ? SNAPSHOT.ts : 0;
try { const s = JSON.parse(localStorage.getItem('rca26') || '{}'); fChamp = s.c || ''; fClass = s.k || ''; } catch (e) {}
const $ = id => document.getElementById(id);
const hm = new Intl.DateTimeFormat('pt-PT', {timeZone: TZ, hour: '2-digit', minute: '2-digit'});
const hms = new Intl.DateTimeFormat('pt-PT', {timeZone: TZ, hour: '2-digit', minute: '2-digit', second: '2-digit'});
const dayf = new Intl.DateTimeFormat('pt-PT', {timeZone: TZ, weekday: 'long', day: 'numeric'});
const esc = s => String(s ?? '').replace(/[&"]/g, c => ({'&':'&','':'>','"':'"'}[c]));
function fmt(t, dec = 1) {
if (t == null || !isFinite(t) || t 0)) return '';
return '+' + (t (t > 0 ? (m / t * 3.6).toFixed(1) : '');
/* ---------- data ---------- */
async function getJSON(u) {
const r = await fetch(API + u, {cache: 'no-store'});
if (!r.ok) throw new Error(r.status);
return (await r.json()).event.data;
}
async function loadLive() {
const sp = (await getJSON(`specials/${RALLY}.json`)).itineraries[0];
const pa = (await getJSON(`participants/${RALLY}.json`)).map(p => [p.id,p.ident,p.pilot,p.copilot,p.vehicle,p.class,p.attributes,p.active,p.abandon_special_id,p.abandon_reason,p.team,p.pilot_nat]);
const specials = sp.specials.map(s => [s.id,s.special_name,s.name_extra,s.meters,s.state,s.ut_ini,s.compute,s.decimals,s.runs]);
const cls = {}, chr = {};
await Promise.all(sp.specials.filter(s => s.state > 0).map(async s => {
const [c, ch] = await Promise.all([
getJSON(`classification/${RALLY}.json?itinerary_id=${sp.id}&special_id=${s.id}`),
getJSON(`chronos2/${RALLY}.json?special_id=${s.id}`)]);
cls[s.id] = {
s: (c.special || []).map(x => [x.participant_id, x.time, x.penalization, x.total, x.runs]),
a: (c.accumulated || []).map(x => [x.participant_id, x.time ?? null, x.penalization ?? null, x.total ?? x.time ?? null])
};
chr[s.id] = ch.map(c => [c.participant_id, c.run, c.ut_start, c.time, c.ut_finish_original]);
}));
return {it: sp.id, pa, specials, cls, chr, ts: Date.now()};
}
/* ---------- derived ---------- */
let P = new Map();
function index() {
P = new Map(D.pa.map(p => [p[0], {id:p[0], no:p[1], pilot:p[2], copilot:p[3], car:p[4], cls:p[5], champs:(p[6]||'').split('||').filter(Boolean), active:p[7], outSs:p[8], outWhy:p[9], team:p[10], nat:p[11]}]));
}
const stages = () => D.specials.map(s => ({id:s[0], code:s[1], name:s[2], m:s[3], state:s[4], ini:s[5], compute:s[6], dec:s[7], runs:s[8]}));
const nowSec = () => (live ? Date.now() : D.ts) / 1000;
function defaultStage() {
const st = stages();
const running = st.find(s => s.state === 1);
if (running) return running.id;
const done = st.filter(s => s.state > 0);
return (done.length ? done[done.length - 1] : st[0]).id;
}
function passes(f) {
if (fChamp && !f.champs.includes(fChamp)) return false;
if (fClass && f.cls !== fClass) return false;
return true;
}
/* ---------- render ---------- */
function renderStatus() {
$('dot').className = 'dot' + (live ? ' live' : '');
const when = lastOk ? hms.format(new Date(lastOk)) : '—';
$('statusText').innerHTML = live
? `Ao vivo · atualizado às ${when}`
: `Instantâneo das ${when} · sem ligação à cronometragem, a tentar de novo`;
}
function renderStrip() {
const byDay = new Map();
for (const s of stages()) {
const d = dayf.format(new Date(s.ini * 1000));
if (!byDay.has(d)) byDay.set(d, []);
byDay.get(d).push(s);
}
const lab = {0:'Por iniciar', 1:'A decorrer', 2:'Terminado'};
$('strip').innerHTML = [...byDay].map(([d, list]) => `Pos Nº Equipa Carro Classe
${showPen ? 'Pen. ' : ''}Tempo Dif. 1.º Dif. ant.
${view === 'stage' ? 'km/h ' : ''}${isShk && view === 'stage' ? 'Passagens ' : ''} `;
const body = list.map((r, i) => {
const prev = i ? list[i-1].total : null;
const f = r.f;
let runsCell = '';
if (isShk && view === 'stage') {
const rs = runsOf(f.id);
runsCell = `${rs.map(x => `${fmt(x[3], s.dec)}`).join(' · ')} `;
}
return `
${i+1}
${esc(f.no)}
${esc(f.car)}
${esc(f.cls)}
${showPen ? `${r.pen > 0 ? fmt(r.pen, 0) : ''} ` : ''}
${fmt(r.total, s.dec)}
${i ? gap(r.total - lead, s.dec) : ''}
${i ? gap(r.total - prev, s.dec) : ''}
${view === 'stage' ? `${kmh(s.m, r.time)} ` : ''}
${runsCell}
`;
}).join('');
$('tablebox').innerHTML = `${head}${body}
`;
}
function renderSide() {
const s = stages().find(x => x.id === sel);
const chr = D.chr[sel] || [];
const now = nowSec();
const on = chr.filter(x => x[2] && x[2] 0) && !x[4]).sort((a,b) => a[2]-b[2]);
const next = chr.filter(x => x[2] && x[2] > now).sort((a,b) => a[2]-b[2]).slice(0, 4);
const row = (x, isNext) => {
const f = P.get(x[0]); if (!f) return '';
const t = isNext ? 'às ' + hm.format(new Date(x[2]*1000)) : fmt(now - x[2], 0);
return `
${esc(c.car)}
${Math.round(c.speed)} km/h · GPS ${hms.format(new Date(c.ts * 1000))}${sec ? `
${esc(secCode(sec))} km ${(c.on.along/1000).toFixed(2).replace('.',',')}` : ''}
Abrir no Google Maps`); } for (const [id, m] of carMk) if (!keep.has(id)) { m.remove(); carMk.delete(id); } const ago = Math.round((Date.now() - lastPosOk) / 1000); $('mStatus').textContent = `${cars.length} carros com GPS · posições atualizadas às ${hms.format(new Date(lastPosOk))} (a cada 5 s)`; } function focusCar(id) { const c = cars.find(x => x.id === id); if (!c || !map) return; showTab('map'); map.setView([c.lat, c.lng], Math.max(map.getZoom(), 15)); const m = carMk.get(id); if (m) m.openPopup(); } function renderCars() { if (!map) return; const s = secs.find(x => x.section === mSel); const now = Date.now() / 1000; const on = cars.filter(c => !c.org && c.on && c.on.sec === mSel).sort((a, b) => b.on.along - a.on.along); $('mCarsTitle').textContent = s ? `No troço ${secCode(s)} · ${on.length}` : 'No troço'; $('mCars').innerHTML = on.length ? on.map(c => { const st = carState(c, now), pct = Math.min(100, c.on.along / c.on.len * 100); const right = st.k === 'alert' ? `parado` : st.k === 'stale' ? `sem sinal` : `${Math.round(c.speed)} km/h`; return `
`;
}).join('') : `
Campeonato de Portugal de Ralis · 2–3 outubro
Rallye Casinos do Algarve 2026
A carregar…
Alertas
No troço
${esc(d)}${list.map(s => `
`).join('')}
`).join('');
$('strip').querySelectorAll('.st').forEach(b => b.onclick = () => { sel = +b.dataset.id; render(); });
}
function renderFilters() {
const champs = new Set(), classes = new Set();
for (const f of P.values()) { f.champs.forEach(c => champs.add(c)); if (f.cls) classes.add(f.cls); }
const fill = (el, vals, cur, all) => {
el.innerHTML = `${all}` + [...vals].sort((a,b)=>a.localeCompare(b,'pt',{numeric:true})).map(v => `${esc(v)}`).join('');
};
fill($('fChamp'), champs, fChamp, 'Todos');
fill($('fClass'), classes, fClass, 'Todas');
}
function renderStage() {
const s = stages().find(x => x.id === sel);
const c = D.cls[sel];
const lab = {0:'Por iniciar', 1:'A decorrer', 2:'Terminado'};
const finishers = c ? c.s.length : 0;
const best = c && c.s.length ? Math.min(...c.s.map(r => r[3]).filter(x => x > 0)) : null;
$('stagehead').innerHTML = `
${esc(s.code)} · ${esc(s.name)}
${lab[s.state] || '—'} ${(s.m/1000).toFixed(2).replace('.',',')} km1.º carro ${hm.format(new Date(s.ini*1000))}${finishers ? `${finishers} classificados` : ''}${best ? `melhor média ${kmh(s.m,best)} km/h` : ''}`; const hasOverall = c && c.a && c.a.length; $('vOverall').disabled = !hasOverall; if (!hasOverall && view === 'overall') view = 'stage'; $('vStage').setAttribute('aria-pressed', view === 'stage'); $('vOverall').setAttribute('aria-pressed', view === 'overall'); const rows = c ? (view === 'overall' ? c.a : c.s) : []; const list = rows.map(r => ({f: P.get(r[0]), time: r[1], pen: r[2], total: r[3] ?? r[1], runs: r[4]})).filter(r => r.f && passes(r.f)); if (!list.length) { const why = !c || !rows.length ? (s.state === 0 ? `Ainda sem temposO primeiro carro parte às ${hm.format(new Date(s.ini*1000))}. A tabela preenche-se à medida que os carros chegam ao fim do troço.` : `Sem tempos publicadosA cronometragem ainda não publicou resultados deste troço.`) : `Nenhum carro neste filtroMude o campeonato ou a classe para ver mais equipas.`; $('tablebox').innerHTML = `${why}
`;
return;
}
const showPen = list.some(r => r.pen > 0);
const isShk = s.runs > 1;
const chr = D.chr[sel] || [];
const runsOf = pid => chr.filter(x => x[0] === pid && x[3] > 0).sort((a,b) => a[1]-b[1]);
const lead = list[0].total;
const head = `${esc(f.pilot)}${f.nat && f.nat !== 'PRT' ? ` ${esc(f.nat)}` : ''}
${esc(f.copilot)}
${esc(f.no)}${t}
`;
};
let html = `${esc(f.pilot)}
${esc(f.car)}
${on.length ? '' : ''}Em pista · ${esc(s.code)}
`; if (on.length) html += `${on.map(x => row(x)).join('')}
`;
else html += `${s.state === 2 ? 'Troço terminado. Nenhum carro em pista.' : s.state === 0 ? 'Ainda nenhum carro partiu neste troço.' : 'Nenhum carro em pista neste momento.'}
`; if (next.length) html += `Próximas partidas
${next.map(x => row(x, true)).join('')}
`;
if (!live) html += `Situação à hora do instantâneo.
`; $('onstagePanel').innerHTML = html; const out = [...P.values()].filter(f => f.outSs > 0); const stByid = new Map(stages().map(x => [x.id, x.code])); const done = stages().filter(x => x.state === 2).length; $('infoPanel').innerHTML = `Prova
- Inscritos
- ${P.size}
- Troços concluídos
- ${done} / ${stages().length}
- Km cronometrados
- ${(stages().filter(x=>x.compute).reduce((a,x)=>a+x.m,0)/1000).toFixed(2).replace('.',',')}
- Desistências
- ${out.length}
${out.map(f => `
` : ''}`;
}
function render() {
if (!D) { $('tablebox').innerHTML = '${esc(f.no)}${esc(stByid.get(f.outSs) || '')}
`).join('')}${esc(f.pilot)}
${esc(f.outWhy || 'Desistência')}
Sem dadosNão foi possível obter a cronometragem.
'; renderStatus(); return; }
index();
if (sel == null || !stages().some(s => s.id === sel)) sel = defaultStage();
renderStatus(); renderStrip(); renderFilters(); renderStage(); renderSide();
}
$('vStage').onclick = () => { view = 'stage'; renderStage(); };
$('vOverall').onclick = () => { if (!$('vOverall').disabled) { view = 'overall'; renderStage(); } };
const saveF = () => { try { localStorage.setItem('rca26', JSON.stringify({c: fChamp, k: fClass})); } catch (e) {} };
$('fChamp').onchange = e => { fChamp = e.target.value; saveF(); renderStage(); };
$('fClass').onchange = e => { fClass = e.target.value; saveF(); renderStage(); };
async function tick() {
try {
const fresh = await loadLive();
const wasLive = live;
D = fresh; live = true; lastOk = fresh.ts;
if (!wasLive) sel = null;
render();
} catch (e) {
if (live) renderStatus();
}
}
/* ================= LIVE MAP ================= */
const TRK = {rest: 'https://rest.anube.es/rallyrest/default/api', sub: 9136, qs: 'rally=rally9136&port=auto&token=jkdhfkjafhkas'};
let needFit = false, map = null, mapState = 'idle', secs = [], geo = {}, pathLayers = {}, endMarkers = [], carMk = new Map(), mSel = null;
let lastAlHtml = '', showOrg = false, thr = 60, soundOn = false, audioCtx = null, cars = [], lastPosOk = 0, alerted = new Set(), seen = new Set(), baseSat, baseRoad;
const stopSince = new Map();
try { const s = JSON.parse(localStorage.getItem('rca26m') || '{}'); if (s.t) thr = s.t; } catch (e) {}
$('mThr').value = String(thr);
async function tget(path) {
const r = await fetch(`${TRK.rest}/${path}${path.includes('?') ? '&' : '?'}${TRK.qs}`, {cache: 'no-store'});
if (!r.ok) throw new Error('HTTP ' + r.status);
return (await r.json()).event.data;
}
const gmaps = (lat, lng) => `https://www.google.com/maps/search/?api=1&query=${lat.toFixed(6)},${lng.toFixed(6)}`;
/* geometry: local metric projection, nearest point + distance along path */
const LAT0 = 37.15, KX = 111320 * Math.cos(LAT0 * Math.PI / 180), KY = 110540;
function prepPath(pts) {
const xy = pts.map(([la, lo]) => [lo * KX, la * KY]); const cum = [0];
for (let i = 1; i < xy.length; i++) cum.push(cum[i-1] + Math.hypot(xy[i][0]-xy[i-1][0], xy[i][1]-xy[i-1][1]));
return {pts, xy, cum, len: cum[cum.length - 1] || 0};
}
function nearest(g, lat, lng) {
const px = lng * KX, py = lat * KY; let best = {d: Infinity, along: 0};
for (let i = 1; i < g.xy.length; i++) {
const [ax, ay] = g.xy[i-1], [bx, by] = g.xy[i]; const dx = bx-ax, dy = by-ay, L2 = dx*dx + dy*dy;
let t = L2 ? ((px-ax)*dx + (py-ay)*dy) / L2 : 0; t = Math.max(0, Math.min(1, t));
const d = Math.hypot(px - (ax + t*dx), py - (ay + t*dy));
if (d !s.closed && now >= s.ut_ini - 1800 && now s.name_timing || s.name;
const secName = s => { const st = D && D.specials.find(x => x[1] === s.name_timing); return st ? st[2] : (s.description || ''); };
async function initMap() {
if (mapState !== 'idle') return;
mapState = 'loading';
$('mStatus').textContent = 'A carregar troços e posições…';
try {
if (!window.L) throw new Error('no-leaflet');
const sd = await tget(`sections/${TRK.sub}.json`);
secs = sd.sections.filter(s => !s.oculto && s.track_visible !== 0);
/* Anube attaches some routes to the wrong section id and leaves repeated passes empty,
so pool every route and give each section the one that starts at its official start point. */
const pool = (await Promise.all(secs.map(s => tget(`section_path/${s.section}.json?also_hidden=1`).catch(() => ({points: []})))))
.map(d => (d.points || []).map(p => [p[1], p[0]])).filter(p => p.length > 1).map(prepPath);
for (const s of secs) {
const sx = s.longitude_ini * KX, sy = s.latitude_ini * KY;
let best = null, bd = Infinity;
for (const g of pool) { const d = Math.hypot(g.xy[0][0] - sx, g.xy[0][1] - sy); if (d < bd) { bd = d; best = g; } }
if (best && bd < 400) geo[s.section] = best;
}
} catch (e) {
mapState = 'failed';
$('map').outerHTML = `Sem ligação ao GPS da AnubeNão foi possível carregar os troços e as posições. Recarregue a página dentro de alguns instantes.
`;
$('mStatus').textContent = '';
return;
}
baseSat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {maxZoom: 19, attribution: 'Imagens © Esri'});
baseRoad = L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {maxZoom: 19, subdomains: 'abcd', attribution: '© OpenStreetMap · © CARTO'});
map = L.map('map', {zoomControl: true, layers: [baseSat]}).setView([37.17, -8.6], 10);
const now = Date.now() / 1000;
const pick = secs.find(s => secActive(s, now)) || secs.find(s => !s.closed && s.ut_ini > now) || secs[secs.length - 1];
mSel = pick ? pick.section : null;
$('mSec').innerHTML = secs.map(s => `${esc(secCode(s))} · ${esc(secName(s))} · ${hm.format(new Date(s.ut_ini * 1000))}${s.closed ? ' (terminado)' : ''}`).join('');
$('mSec').value = String(mSel);
drawPaths(true);
mapState = 'ready';
pollPos();
setInterval(pollPos, 5000);
setInterval(renderAlerts, 1000);
}
function drawPaths(fit) {
Object.values(pathLayers).forEach(l => l.remove()); endMarkers.forEach(m => m.remove()); pathLayers = {}; endMarkers = [];
for (const s of secs) {
const g = geo[s.section]; if (!g || g.pts.length { mSel = s.section; $('mSec').value = String(mSel); drawPaths(true); renderCars(); });
if (isSel) {
endMarkers.push(L.circleMarker(g.pts[0], {radius: 7, color: '#fff', weight: 2, fillColor: '#12834a', fillOpacity: 1}).addTo(map).bindTooltip('Partida'));
endMarkers.push(L.circleMarker(g.pts[g.pts.length - 1], {radius: 7, color: '#fff', weight: 2, fillColor: '#c62828', fillOpacity: 1}).addTo(map).bindTooltip('Chegada'));
}
}
if (pathLayers[mSel]) pathLayers[mSel].bringToFront();
const selS = secs.find(s => s.section === mSel);
if (selS && !geo[mSel]) { endMarkers.push(L.circleMarker([selS.latitude_ini, selS.longitude_ini], {radius: 7, color: '#fff', weight: 2, fillColor: '#12834a', fillOpacity: 1}).addTo(map).bindTooltip('Partida (sem traçado publicado)')); }
if (fit && $('mapView').hidden) { needFit = true; return; }
if (fit && pathLayers[mSel]) map.fitBounds(pathLayers[mSel].getBounds(), {padding: [30, 30]});
else if (fit && selS) map.setView([selS.latitude_ini, selS.longitude_ini], 15);
}
function beep() {
if (!soundOn || !audioCtx) return;
const t0 = audioCtx.currentTime;
[0, .28, .56].forEach(dt => {
const o = audioCtx.createOscillator(), gn = audioCtx.createGain();
o.type = 'square'; o.frequency.value = 880; gn.gain.setValueAtTime(.0001, t0 + dt);
gn.gain.exponentialRampToValueAtTime(.25, t0 + dt + .02); gn.gain.exponentialRampToValueAtTime(.0001, t0 + dt + .2);
o.connect(gn).connect(audioCtx.destination); o.start(t0 + dt); o.stop(t0 + dt + .22);
});
}
async function pollPos() {
let list;
try { list = (await tget(`participants/${TRK.sub}.json`)).participants || []; }
catch (e) { $('mStatus').textContent = 'Sem ligação ao GPS. Nova tentativa em 5 s…'; return; }
lastPosOk = Date.now();
const now = Date.now() / 1000;
const active = secs.filter(s => secActive(s, now) || s.section === mSel);
const nos = new Set([...P.values()].map(f => f.no));
const isComp = p => nos.size ? nos.has(String(p.dorsal)) : /^[1-9]\d{0,2}$/.test(String(p.dorsal)) && !['200','300'].includes(String(p.dorsal));
cars = list.filter(p => p.latitude && p.longitude && (isComp(p) || showOrg)).map(p => {
const lat = +p.latitude, lng = +p.longitude, speed = +p.speed || 0, ts = +p.unixtime_gps || 0;
let on = null;
for (const s of active) {
const g = geo[s.section]; if (!g) continue;
const n = nearest(g, lat, lng);
if (n.d < 80 && (!on || n.d < on.d)) on = {sec: s.section, d: n.d, along: n.along, len: g.len};
}
if (speed f.no === String(p.dorsal)) : null;
return {id: p.participant, no: String(p.dorsal), pilot: t ? t.pilot : p.piloto, car: t ? t.car : p.vehiculo_modelo, cls: t ? t.cls : '',
lat, lng, speed, ts, on, org: !isComp(p), onTrack: +p.on_track === 1, anube: isComp(p) ? (p.alert_text || '') : ''};
});
updateMarkers();
renderCars();
renderAlerts();
}
function carState(c, now) {
if (c.org) return {k: 'org'};
const stale = now - c.ts > 90;
const onStage = !!c.on;
const nearEnds = c.on && (c.on.along c.on.len - 200);
const dur = stopSince.has(c.id) ? now - stopSince.get(c.id) : 0;
if (c.anube) return {k: 'alert', why: c.anube, dur};
if (onStage && !nearEnds && !stale && c.speed = thr) return {k: 'alert', why: 'Parado', dur};
if (onStage && !nearEnds && stale) return {k: 'stale', why: 'Sem sinal GPS', dur: now - c.ts};
if (stale) return {k: 'stale'};
if (!onStage) return {k: 'off'};
if (c.speed < 5) return {k: 'stop', dur};
return {k: 'move'};
}
function updateMarkers() {
const now = Date.now() / 1000, keep = new Set();
for (const c of cars) {
keep.add(c.id);
const st = carState(c, now);
const html = `${esc(c.no)}`;
let m = carMk.get(c.id);
if (!m) {
m = L.marker([c.lat, c.lng], {icon: L.divIcon({className: 'mkwrap', html, iconSize: [0, 0]})}).addTo(map);
m.bindPopup('');
carMk.set(c.id, m);
} else { m.setLatLng([c.lat, c.lng]); m.setIcon(L.divIcon({className: 'mkwrap', html, iconSize: [0, 0]})); }
m.setZIndexOffset(st.k === 'alert' ? 1000 : st.k === 'off' ? -100 : 0);
const sec = c.on ? secs.find(s => s.section === c.on.sec) : null;
m.setPopupContent(`#${esc(c.no)} ${esc(c.pilot)}${esc(c.car)}
${Math.round(c.speed)} km/h · GPS ${hms.format(new Date(c.ts * 1000))}${sec ? `
${esc(secCode(sec))} km ${(c.on.along/1000).toFixed(2).replace('.',',')}` : ''}
Abrir no Google Maps`); } for (const [id, m] of carMk) if (!keep.has(id)) { m.remove(); carMk.delete(id); } const ago = Math.round((Date.now() - lastPosOk) / 1000); $('mStatus').textContent = `${cars.length} carros com GPS · posições atualizadas às ${hms.format(new Date(lastPosOk))} (a cada 5 s)`; } function focusCar(id) { const c = cars.find(x => x.id === id); if (!c || !map) return; showTab('map'); map.setView([c.lat, c.lng], Math.max(map.getZoom(), 15)); const m = carMk.get(id); if (m) m.openPopup(); } function renderCars() { if (!map) return; const s = secs.find(x => x.section === mSel); const now = Date.now() / 1000; const on = cars.filter(c => !c.org && c.on && c.on.sec === mSel).sort((a, b) => b.on.along - a.on.along); $('mCarsTitle').textContent = s ? `No troço ${secCode(s)} · ${on.length}` : 'No troço'; $('mCars').innerHTML = on.length ? on.map(c => { const st = carState(c, now), pct = Math.min(100, c.on.along / c.on.len * 100); const right = st.k === 'alert' ? `parado` : st.k === 'stale' ? `sem sinal` : `${Math.round(c.speed)} km/h`; return `
${esc(c.no)}${right}
`;
}).join('') : `${esc(c.pilot)}
km ${(c.on.along/1000).toFixed(2).replace('.',',')} de ${(c.on.len/1000).toFixed(2).replace('.',',')}
Nenhum carro dentro deste troço agora.
`; $('mCars').querySelectorAll('.car-on').forEach(el => el.onclick = () => focusCar(+el.dataset.id)); } const mmss = s => { s = Math.max(0, Math.round(s)); return `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`; }; function renderAlerts() { if (!map) return; const now = Date.now() / 1000; const list = cars.map(c => ({c, st: carState(c, now)})).filter(x => x.st.why).sort((a, b) => b.st.dur - a.st.dur); let fresh = false; for (const {c, st} of list) { const key = c.id + ':' + st.why + ':' + (stopSince.get(c.id) || c.ts); if (!alerted.has(key)) { alerted.add(key); if (st.k === 'alert') fresh = true; } c._key = key; } if (fresh) { beep(); try { if (soundOn && 'Notification' in window && Notification.permission === 'granted') { const a = list.find(x => x.st.k === 'alert'); new Notification(`#${a.c.no} ${a.c.pilot}: ${a.st.why}`, {body: a.c.on ? `${secCode(secs.find(s => s.section === a.c.on.sec))} km ${(a.c.on.along/1000).toFixed(1)}` : ''}); } } catch (e) {} } const alHtml = list.length ? list.map(({c, st}) => { const sec = c.on ? secs.find(s => s.section === c.on.sec) : null; const isSeen = seen.has(c._key); return `${esc(c.no)}${esc(st.why)}
${esc(c.pilot)}
${sec ? `${esc(secCode(sec))} · km ${(c.on.along/1000).toFixed(2).replace('.',',')}` : 'Fora dos troços'}
Google Maps${isSeen ? '' : ``}
Sem alertas. Um carro dentro de um troço com menos de 5 km/h durante mais de ${thr >= 60 ? (thr/60) + ' min' : thr + ' s'} aparece aqui, com som se estiver ativado. Paragens junto à partida e à chegada não contam.
`; if (alHtml !== lastAlHtml) { lastAlHtml = alHtml; $('mAlerts').innerHTML = alHtml; $('mAlerts').querySelectorAll('[data-go]').forEach(b => b.onclick = () => focusCar(+b.dataset.go)); $('mAlerts').querySelectorAll('[data-seen]').forEach(b => b.onclick = () => { seen.add(b.dataset.seen); renderAlerts(); }); } const durs = new Map(list.map(x => [String(x.c.id), mmss(x.st.dur)])); $('mAlerts').querySelectorAll('.dur').forEach(el => el.textContent = durs.get(el.dataset.id) || ''); const open = list.filter(x => x.st.k === 'alert' && !seen.has(x.c._key)); $('tabBadge').hidden = !open.length; $('tabBadge').textContent = open.length; $('alertBar').hidden = !open.length; if (open.length) { const a = open[0]; $('alertBarText').textContent = `#${a.c.no} ${a.c.pilot} parado há ${mmss(a.st.dur)}` + (open.length > 1 ? ` · mais ${open.length - 1} alerta${open.length > 2 ? 's' : ''}` : ''); $('alertBarGo').onclick = () => focusCar(a.c.id); } document.title = (open.length ? `(${open.length}) ALERTA · ` : '') + 'Rallye Casinos do Algarve 2026'; } function showTab(which) { const isMap = which === 'map'; $('tabTimes').setAttribute('aria-selected', !isMap); $('tabMap').setAttribute('aria-selected', isMap); $('timesView').hidden = isMap; $('mapView').hidden = !isMap; if (isMap) { initMap(); if (map) setTimeout(() => { map.invalidateSize(); if (needFit && pathLayers[mSel]) { map.fitBounds(pathLayers[mSel].getBounds(), {padding: [30, 30]}); needFit = false; } }, 50); } } $('tabTimes').onclick = () => showTab('times'); $('tabMap').onclick = () => showTab('map'); $('mSec').onchange = e => { mSel = +e.target.value; drawPaths(true); pollPos(); }; $('mThr').onchange = e => { thr = +e.target.value; try { localStorage.setItem('rca26m', JSON.stringify({t: thr})); } catch (er) {} renderAlerts(); renderCars(); }; $('mSound').onclick = () => { soundOn = !soundOn; $('mSound').classList.toggle('on', soundOn); $('mSound').setAttribute('aria-pressed', soundOn); $('mSound').textContent = soundOn ? 'Som de alerta ligado' : 'Ativar som de alerta'; if (soundOn) { try { audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)(); audioCtx.resume(); beep(); } catch (e) {} try { if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission(); } catch (e) {} } }; const setBase = sat => { if (!map) return; if (sat) { map.removeLayer(baseRoad); baseSat.addTo(map); } else { map.removeLayer(baseSat); baseRoad.addTo(map); } $('mSat').setAttribute('aria-pressed', sat); $('mRoad').setAttribute('aria-pressed', !sat); }; $('mOrg').onchange = e => { showOrg = e.target.checked; pollPos(); }; $('mSat').onclick = () => setBase(true); $('mRoad').onclick = () => setBase(false); /* start the map in the background so alerts work even on the Tempos tab */ if (location.hash === '#mapa') showTab('map'); else setTimeout(initMap, 300); render(); tick(); setInterval(tick, 15000); setInterval(() => { if (live) renderSide(); }, 1000);