fix: hover tooltip miss on dense days + full mobile support

- hover search on the detail canvas used a fixed +/-3 array-index
  window; single-lane days run up to 53 documents, so most hovers
  in dense clusters found nothing. now scans by actual time radius.
- add touch support: one-finger pan, two-finger pinch-zoom, and tap
  for tooltip on the detail canvas; touch drag/resize on the overview
  brush.
- responsive pass: controls stack full-width on narrow screens,
  dropdown panels expand inline instead of floating off-screen, fix
  a flex-direction bug that made the search box 220px tall on mobile.
- README: drop build/regeneration instructions (no collaborators),
  link to the related vorcaros project.
This commit is contained in:
2026-09-13 12:12:40 +02:00
parent 891d58ec55
commit 07fcf1164b
2 changed files with 115 additions and 30 deletions
+1 -13
View File
@@ -28,7 +28,7 @@ Este projeto troca aquele gráfico por uma timeline de verdade:
index.html página estática (self-contained: HTML + CSS + JS, sem build step) index.html página estática (self-contained: HTML + CSS + JS, sem build step)
docs.json um registro por documento (id, instituição, data, texto completo, tipo, pessoas) docs.json um registro por documento (id, instituição, data, texto completo, tipo, pessoas)
people.json catálogo de pessoas identificadas: {key, count, role} people.json catálogo de pessoas identificadas: {key, count, role}
pipeline/ scripts e dados intermediários da extração (ver abaixo) pipeline/ scripts e dados intermediários da extração (referência, não é preciso rodar nada)
``` ```
## Como os dados foram montados ## Como os dados foram montados
@@ -67,18 +67,6 @@ pipeline/ scripts e dados intermediários da extração (ver abaixo)
classificações de papel aproximadas. Trate como um ponto de partida para busca, não como fonte classificações de papel aproximadas. Trate como um ponto de partida para busca, não como fonte
definitiva de identidade ou qualificação processual. definitiva de identidade ou qualificação processual.
## Rodando localmente
Não há build step — é HTML/CSS/JS puro. Basta servir a pasta com qualquer servidor estático:
```bash
python3 -m http.server 8000
# abrir http://localhost:8000/
```
Para regerar `docs.json` e `people.json` a partir dos dados originais (`gitgraph.md`,
`TIMELINE.md`, ambos fora deste repositório), ver os scripts em `pipeline/`.
## Projeto relacionado ## Projeto relacionado
[**vorcaros**](https://rafapolo.github.io/vorcaros/) — visualização em grafo da rede societária [**vorcaros**](https://rafapolo.github.io/vorcaros/) — visualização em grafo da rede societária
+114 -17
View File
@@ -280,6 +280,20 @@
@media (max-width: 640px){ @media (max-width: 640px){
.lane-sidebar{ width:92px; } .lane-sidebar{ width:92px; }
.lane-row .lbl b{ font-size:11.5px; } .lane-row .lbl b{ font-size:11.5px; }
.controls{ flex-direction:column; align-items:stretch; }
.search{ flex:none; max-width:none; }
.dropdown{ width:100%; }
.dropdown-btn{ width:100%; justify-content:space-between; }
.dropdown-panel, .dropdown-panel-wide{ position:static; width:100%; margin-top:6px; box-shadow:none; }
.legend{ width:100%; }
.spacer{ display:none; }
.presets{ width:100%; }
.presets button{ flex:1 1 auto; }
.segmented{ width:100%; }
.segmented button{ flex:1; }
.stat{ min-width:0; flex-basis:calc(50% - 5px); }
} }
</style> </style>
@@ -1054,6 +1068,60 @@
dtCanvas.addEventListener("mouseleave", function(){ clearHover(); }); dtCanvas.addEventListener("mouseleave", function(){ clearHover(); });
// touch: one-finger drag pans, two-finger pinch zooms, a tap (no drag) shows the tooltip
var touchState = null;
function touchDist(touches){
return Math.hypot(touches[0].clientX-touches[1].clientX, touches[0].clientY-touches[1].clientY);
}
function touchMidX(touches){ return (touches[0].clientX+touches[1].clientX)/2; }
dtCanvas.addEventListener("touchstart", function(e){
if (e.touches.length === 1){
var t = e.touches[0];
touchState = { mode:"pan", x:t.clientX, y:t.clientY, t0:state.t0, t1:state.t1, moved:false, time:Date.now() };
} else if (e.touches.length === 2){
var rect = dtCanvas.getBoundingClientRect();
touchState = { mode:"pinch", dist:touchDist(e.touches), anchorMs: invDetail(touchMidX(e.touches)-rect.left), t0:state.t0, t1:state.t1 };
}
}, { passive:true });
dtCanvas.addEventListener("touchmove", function(e){
if (!touchState) return;
e.preventDefault();
if (touchState.mode==="pan" && e.touches.length===1){
var t = e.touches[0];
var dx = t.clientX - touchState.x;
if (Math.abs(dx) > 4 || Math.abs(t.clientY-touchState.y) > 4) touchState.moved = true;
var dt = -(dx/dtWidth)*(touchState.t1-touchState.t0);
var span = touchState.t1-touchState.t0;
var nt0 = touchState.t0+dt, nt1 = touchState.t1+dt;
if (nt0 < state.fullMin){ nt0=state.fullMin; nt1=nt0+span; }
if (nt1 > state.fullMax){ nt1=state.fullMax; nt0=nt1-span; }
state.t0=nt0; state.t1=nt1;
drawDetail(); positionBrush();
} else if (touchState.mode==="pinch" && e.touches.length===2){
var newDist = touchDist(e.touches);
var factor = touchState.dist / Math.max(newDist,1);
var span = (touchState.t1-touchState.t0)*factor;
var minSpan = 6*3600000, maxSpan = state.fullMax-state.fullMin;
span = Math.max(minSpan, Math.min(maxSpan, span));
var anchor = touchState.anchorMs;
var ratio = (anchor-touchState.t0)/(touchState.t1-touchState.t0);
var nt0p = anchor - ratio*span, nt1p = anchor + (1-ratio)*span;
if (nt0p<state.fullMin){ nt1p+= state.fullMin-nt0p; nt0p=state.fullMin; }
if (nt1p>state.fullMax){ nt0p-= nt1p-state.fullMax; nt1p=state.fullMax; }
state.t0=Math.max(state.fullMin,nt0p); state.t1=Math.min(state.fullMax,nt1p);
drawDetail(); positionBrush();
}
}, { passive:false });
dtCanvas.addEventListener("touchend", function(){
if (touchState && touchState.mode==="pan" && !touchState.moved && (Date.now()-touchState.time)<400){
handleHover({ clientX: touchState.x, clientY: touchState.y });
}
touchState = null;
});
function handleHover(e){ function handleHover(e){
var rect = dtCanvas.getBoundingClientRect(); var rect = dtCanvas.getBoundingClientRect();
var mx = e.clientX-rect.left, my = e.clientY-rect.top; var mx = e.clientX-rect.left, my = e.clientY-rect.top;
@@ -1062,16 +1130,24 @@
var arr = state.byLane[LANES[li].key]; var arr = state.byLane[LANES[li].key];
if (!arr.length){ clearHover(); return; } if (!arr.length){ clearHover(); return; }
var targetMs = invDetail(mx); var targetMs = invDetail(mx);
// same-day clusters run up to 50+ documents in a single lane, so the
// candidate window must be bounded by actual time proximity, not a
// fixed number of array slots either side of the binary-search hit.
var msPerPixel = Math.abs((state.t1-state.t0)/dtWidth);
var timeRadius = 18 * msPerPixel;
var lo=0, hi=arr.length-1; var lo=0, hi=arr.length-1;
while (lo<hi){ var mid=(lo+hi)>>1; if (arr[mid].ms<targetMs) lo=mid+1; else hi=mid; } while (lo<hi){ var mid=(lo+hi)>>1; if (arr[mid].ms<targetMs) lo=mid+1; else hi=mid; }
var best=null, bestD=Infinity; var best=null, bestD=Infinity;
for (var k=Math.max(0,lo-3); k<=Math.min(arr.length-1,lo+3); k++){ function consider(p){
var p = arr[k]; if (!state.activeTypes.has(p.type) || !peopleOk(p)) return;
var x = scaleDetail(p.ms); var x = scaleDetail(p.ms);
var y = li*ROW_H+ROW_H/2+p.jitter; var y = li*ROW_H+ROW_H/2+p.jitter;
var d = Math.hypot(mx-x, my-y); var d = Math.hypot(mx-x, my-y);
if (d<bestD){ bestD=d; best=p; } if (d<bestD){ bestD=d; best=p; }
} }
var k;
for (k=lo; k<arr.length && arr[k].ms<=targetMs+timeRadius; k++){ consider(arr[k]); }
for (k=lo-1; k>=0 && arr[k].ms>=targetMs-timeRadius; k--){ consider(arr[k]); }
if (best && bestD<=16){ showHover(best, li); } if (best && bestD<=16){ showHover(best, li); }
else { clearHover(); } else { clearHover(); }
} }
@@ -1111,36 +1187,57 @@
// overview brush // overview brush
var brush = document.getElementById("brush"); var brush = document.getElementById("brush");
var ovState = null; var ovState = null;
ovCanvas.addEventListener("mousedown", function(e){ var EDGE_PX = 7;
var rect = ovCanvas.getBoundingClientRect(); function startBrushGesture(mx, edgePx){
var mx = e.clientX-rect.left;
var bx0 = scaleFull(state.t0), bx1 = scaleFull(state.t1); var bx0 = scaleFull(state.t0), bx1 = scaleFull(state.t1);
var mode; var mode;
if (Math.abs(mx-bx0)<7) mode="left"; if (Math.abs(mx-bx0)<edgePx) mode="left";
else if (Math.abs(mx-bx1)<7) mode="right"; else if (Math.abs(mx-bx1)<edgePx) mode="right";
else if (mx>bx0 && mx<bx1) mode="move"; else if (mx>bx0 && mx<bx1) mode="move";
else { mode="move"; var span=state.t1-state.t0; var center = state.fullMin+(mx/ovWidth)*(state.fullMax-state.fullMin); else { mode="move"; var span=state.t1-state.t0; var center = state.fullMin+(mx/ovWidth)*(state.fullMax-state.fullMin);
state.t0=center-span/2; state.t1=center+span/2; state.t0=center-span/2; state.t1=center+span/2;
clampT(); drawDetail(); positionBrush(); clampT(); drawDetail(); positionBrush();
} }
ovState = { mode:mode, startX:mx, t0:state.t0, t1:state.t1 }; return { mode:mode, startX:mx, t0:state.t0, t1:state.t1 };
}
function moveBrushGesture(gs, mx){
var dt = (mx-gs.startX)/ovWidth*(state.fullMax-state.fullMin);
if (gs.mode==="move"){ state.t0=gs.t0+dt; state.t1=gs.t1+dt; }
else if (gs.mode==="left"){ state.t0=Math.min(gs.t1-6*3600000, gs.t0+dt); }
else if (gs.mode==="right"){ state.t1=Math.max(gs.t0+6*3600000, gs.t1+dt); }
clampT();
drawDetail(); positionBrush();
}
ovCanvas.addEventListener("mousedown", function(e){
var rect = ovCanvas.getBoundingClientRect();
ovState = startBrushGesture(e.clientX-rect.left, EDGE_PX);
}); });
window.addEventListener("mousemove", function(e){ window.addEventListener("mousemove", function(e){
if (!ovState) return; if (!ovState) return;
var rect = ovCanvas.getBoundingClientRect(); var rect = ovCanvas.getBoundingClientRect();
var mx = e.clientX-rect.left; moveBrushGesture(ovState, e.clientX-rect.left);
var dt = (mx-ovState.startX)/ovWidth*(state.fullMax-state.fullMin);
if (ovState.mode==="move"){ state.t0=ovState.t0+dt; state.t1=ovState.t1+dt; }
else if (ovState.mode==="left"){ state.t0=Math.min(ovState.t1-6*3600000, ovState.t0+dt); }
else if (ovState.mode==="right"){ state.t1=Math.max(ovState.t0+6*3600000, ovState.t1+dt); }
clampT();
drawDetail(); positionBrush();
}); });
window.addEventListener("mouseup", function(){ ovState=null; }); window.addEventListener("mouseup", function(){ ovState=null; });
var ovTouchState = null;
ovCanvas.addEventListener("touchstart", function(e){
if (e.touches.length !== 1) return;
var rect = ovCanvas.getBoundingClientRect();
ovTouchState = startBrushGesture(e.touches[0].clientX-rect.left, 14);
}, { passive:true });
ovCanvas.addEventListener("touchmove", function(e){
if (!ovTouchState || e.touches.length!==1) return;
e.preventDefault();
var rect = ovCanvas.getBoundingClientRect();
moveBrushGesture(ovTouchState, e.touches[0].clientX-rect.left);
}, { passive:false });
ovCanvas.addEventListener("touchend", function(){ ovTouchState=null; });
function clampT(){ function clampT(){
if (state.t0<state.fullMin){ var span=state.t1-state.t0; state.t0=state.fullMin; if(ovState&&ovState.mode==="move") state.t1=state.t0+span; } var gs = ovState || ovTouchState;
if (state.t1>state.fullMax){ var span2=state.t1-state.t0; state.t1=state.fullMax; if(ovState&&ovState.mode==="move") state.t0=state.t1-span2; } if (state.t0<state.fullMin){ var span=state.t1-state.t0; state.t0=state.fullMin; if(gs&&gs.mode==="move") state.t1=state.t0+span; }
if (state.t1>state.fullMax){ var span2=state.t1-state.t0; state.t1=state.fullMax; if(gs&&gs.mode==="move") state.t0=state.t1-span2; }
} }
} }