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
+114 -17
View File
@@ -280,6 +280,20 @@
@media (max-width: 640px){
.lane-sidebar{ width:92px; }
.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>
@@ -1054,6 +1068,60 @@
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){
var rect = dtCanvas.getBoundingClientRect();
var mx = e.clientX-rect.left, my = e.clientY-rect.top;
@@ -1062,16 +1130,24 @@
var arr = state.byLane[LANES[li].key];
if (!arr.length){ clearHover(); return; }
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;
while (lo<hi){ var mid=(lo+hi)>>1; if (arr[mid].ms<targetMs) lo=mid+1; else hi=mid; }
var best=null, bestD=Infinity;
for (var k=Math.max(0,lo-3); k<=Math.min(arr.length-1,lo+3); k++){
var p = arr[k];
function consider(p){
if (!state.activeTypes.has(p.type) || !peopleOk(p)) return;
var x = scaleDetail(p.ms);
var y = li*ROW_H+ROW_H/2+p.jitter;
var d = Math.hypot(mx-x, my-y);
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); }
else { clearHover(); }
}
@@ -1111,36 +1187,57 @@
// overview brush
var brush = document.getElementById("brush");
var ovState = null;
ovCanvas.addEventListener("mousedown", function(e){
var rect = ovCanvas.getBoundingClientRect();
var mx = e.clientX-rect.left;
var EDGE_PX = 7;
function startBrushGesture(mx, edgePx){
var bx0 = scaleFull(state.t0), bx1 = scaleFull(state.t1);
var mode;
if (Math.abs(mx-bx0)<7) mode="left";
else if (Math.abs(mx-bx1)<7) mode="right";
if (Math.abs(mx-bx0)<edgePx) mode="left";
else if (Math.abs(mx-bx1)<edgePx) mode="right";
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);
state.t0=center-span/2; state.t1=center+span/2;
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){
if (!ovState) return;
var rect = ovCanvas.getBoundingClientRect();
var mx = 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();
moveBrushGesture(ovState, e.clientX-rect.left);
});
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(){
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; }
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; }
var gs = ovState || ovTouchState;
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; }
}
}