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:
@@ -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
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user