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