app
Adom Gantt
Public Made by Adomby adom
Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, push
← Commit history
0.41.0: dependency arrows route around the bars instead of through them
6 files changed
+288−15
README.md+2−2@@ -42,7 +42,7 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **Vertical reorder.** Drag bars or labels up/down to reorder rows. Drop indicator shows insertion point. Multi-select reorder moves all selected items together. -**Dependency arrows.** Teal bezier curves with arrowheads show task relationships. Click any bar to see incoming ("needs") and outgoing ("blocks") dependencies. Add or remove dependencies inline.+**Dependency arrows.** Teal routes with rounded elbows and arrowheads show task relationships: out of the source bar's end sideways, down, and into the next bar's start sideways again, steering round any bar in the way rather than through it. Click any bar to see incoming ("needs") and outgoing ("blocks") dependencies. Add or remove dependencies inline. **Legend filtering.** Click any phase, "Milestone", "Dependencies" or "Today" in the legend bar to toggle visibility. Dimmed items fade to 15% opacity; hiding "Today" removes the marker line. @@ -231,7 +231,7 @@ long editing run. | Up / Down | Move the selected row in the table, following the visible order under sorts and filters. They never scroll the pane by themselves; it moves only enough to keep the selected row in view | | Enter | Edit the selected row's name. Inside a cell, commit and step down one row | | Delete | Delete the selected row, undoable. Only when a row is selected and no field is open, so Delete inside an editor just edits text. Backspace is deliberately not bound |-| Shift+D | Dependency mode: click the task that comes first, then the one that waits on it. The armed first task wears an amber ring. Clicking a pair that already has a dependency removes it. Click an arrow to pick it, then Delete twice to remove that one. Stays on so you can chain them. Esc lets go of a picked arrow, then the pick, then leaves |+| Shift+D | Dependency mode: click the task that comes first, then the one that waits on it. The armed first task wears the normal selection outline. Clicking a pair that already has a dependency removes it. Click an arrow to pick it, then Delete twice to remove that one. Stays on so you can chain them. Esc lets go of a picked arrow, then the pick, then leaves | | Shift+N | Open the New panel. Fires only when nothing else is going on, so a capital N typed into a field stays a capital N | | Alt+Up / Alt+Down | Move a row one slot: the focused row in the table, the selected task in the chart. Crossing a lane boundary moves the row into that lane. Under a column sort the row stays inside the run of rows the sort considers tied with it | | Tab / Shift+Tab | In the table, walk the selected row's fields. With no editor open it opens the first editable cell (Shift+Tab the last); with one open it commits and moves to the next. Wraps across rows and around the ends, and follows the visible order under a sort. With no row selected, Tab walks the page's own controls as usual. In the edit panels it is one keypress per field the same way, including the date fields, which Tab leaves whole instead of walking their day, month and year segments |
SKILL.md+2−1@@ -216,9 +216,10 @@ rather than a category. - **New items default their provenance to "Added by <your display name>"**, still an ordinary editable field - **Delete** removes the selected table row (undoable, redoable). Only when a row is selected and no editor, panel, search box or drag is live, so Delete inside a field only edits text. Backspace is not bound. Inert in the chart - **Saving is whole-state and last-write-wins.** Two people editing at once WILL overwrite each other. Each save carries the revision it was based on and a stale save is refused with 409, at which point that tab stops autosaving and offers a reload rather than clobbering the other person-- **Dependency mode** (the Deps button with the chart controls, or **Shift+D**): click the task that comes FIRST, then the task that waits on it, and the dependency is drawn. The armed first task wears an amber ring so you can see what the pair is waiting on. The pair resets and the mode stays on, so a run of dependencies is one sitting. Clicking a pair that ALREADY has a dependency REMOVES it, which is the same removal as the editor's ×: one undo entry, one changelog event. Refuses anything that would make a cycle, naming the shortest loop path. Milestones are not endpoints: a dep is stored as a task id and a milestone in the data file has none. **Clicking an arrow picks it**, in this mode only, and **Delete** twice removes the dependency it stands for (the first press arms and says so, the second commits; any other key, a click or Esc withdraws the question). Outside the mode the arrows take no clicks at all. Esc lets go of a picked arrow, then a half-made pick, then leaves the mode. While the mode is on, bar clicks mean dependencies: no editor, no drag, no reorder. Ctrl+Z undoes the last add or removal without leaving the mode+- **Dependency mode** (the Deps button with the chart controls, or **Shift+D**): click the task that comes FIRST, then the task that waits on it, and the dependency is drawn. The armed first task wears the normal selection outline so you can see what the pair is waiting on. The pair resets and the mode stays on, so a run of dependencies is one sitting. Clicking a pair that ALREADY has a dependency REMOVES it, which is the same removal as the editor's ×: one undo entry, one changelog event. Refuses anything that would make a cycle, naming the shortest loop path. Milestones are not endpoints: a dep is stored as a task id and a milestone in the data file has none. **Clicking an arrow picks it**, in this mode only, and **Delete** twice removes the dependency it stands for (the first press arms and says so, the second commits; any other key, a click or Esc withdraws the question). Outside the mode the arrows take no clicks at all. Esc lets go of a picked arrow, then a half-made pick, then leaves the mode. While the mode is on, bar clicks mean dependencies: no editor, no drag, no reorder. Ctrl+Z undoes the last add or removal without leaving the mode - **Copy IDs**: with rows selected, the table's tools row and the chart's selection chip (and the batch panel) copy the selected items' stable ids, one per line as `id<TAB>name`, for pasting into an AI chat to request batch edits. Ctrl+C does the same when nothing is focused and no text is selected. If the clipboard is refused the ids appear in a selectable box rather than failing silently - **Row multi-select in the table**: ctrl-click toggles a row, shift-click takes the range from the anchor following the VISIBLE order under sorts and filters. It is the SAME selection as the chart's, so rows picked in one lens are selected in the other. Modified clicks never open a cell editor and never start a native text selection; unmodified click-drag still selects cell text. Alt+Up/Down moves the whole set (plan order only; refused under a sort), and Delete removes the whole set as one undo+- **Arrows steer clear of the bars.** A dependency leaves the source bar's END sideways, drops, and arrives at the next bar's START sideways again, with rounded elbows, so which end is the cause is always readable. It routes AROUND the bars in between rather than through them: the drop moves to the nearest clear channel, and a long or backward route runs along the empty lane between two rows rather than across a task that has nothing to do with it. Where a bar lies clean across the only gap, the arrow goes round the end of it. Nothing to configure; it is re-decided on every redraw, including live during a drag - **Deps column** in the table: a narrow read-only indicator, needs and blocks counts with the names on hover, blank when there are none. Clicking it opens the editor where dependencies are changed. Sortable by total count - **Ctrl+Z** to undo (100-level stack) - **Ctrl+Shift+Z** (or **Ctrl+Y**) to redo. Redo only ever replays what undo just took back, so any new edit clears it. It covers every undoable action symmetrically: field edits, snapped drags, row and lane moves, deletes, creates (the row comes back with the same id and stable key), milestone edits and the whole-plan shift. Inside a text field the field's own undo and redo win, exactly as Ctrl+Z already does. Redo is logged to the changelog as its own event, tagged `redo`
docs/BUILD-NOTES.md+98@@ -3,6 +3,104 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-18 (0.41.0, dependency arrows steer clear of the bars)++One change, from Drew, in the client. `server.js` is untouched, so the running server was+never restarted, and no live plan was opened at any point in this round.++**The bug.** 0.38.0 gave dependency arrows their Z: a horizontal run out of the source, a+vertical drop, a horizontal run into the target's start, with a hook for the pairs whose+target begins before their source ends. It was shaped out of the four numbers of the two bars+and nothing else, so it knew where those two bars were and nothing at all about the ones in+between. Drew's screenshot is the consequence: a drop falling straight down the body of an+unrelated task, and a hook's middle run lying along another one, which reads as if that bar+were part of the dependency. On the 160-row bench built for this round, 51 of 72 arrows+crossed a bar they had nothing to do with.++**The trick is that the chart is rows.** Every row carries at most one bar, with the same+empty band above and below it, and that gives three facts the routing is built on:++- a horizontal run along a row BOUNDARY is clear of every bar by construction;+- a horizontal run at a row's own centre can only meet that row's own bar, which is either the+ source behind the exit or the target ahead of the entry;+- so the only thing left to choose is the x of each vertical drop.++Which is why the two SHAPES are exactly the ones 0.38.0 shipped. Nothing new appears on+screen. What is new is that a drop moves to the nearest clear channel when its default x would+cross a bar, and the hook's middle run is snapped to a row boundary instead of landing+wherever the midpoint of the two rows happened to fall. The candidates, in order:++1. the 0.38.0 Z at the midpoint. Tried FIRST, always, and returned the moment it is clear, so+ an uncluttered plan is drawn exactly where this app has drawn it since 0.38.0 and a redraw+ that changes nothing changes nothing;+2. the same Z with its drop nudged to the nearest clear x inside the two stubs. A Z never+ turns back, so it walks the bare distance between the two bars: a clear Z is not merely+ good enough, it is the SHORTEST route there is, and both of these therefore end the search;+3. the S, in a lane between the two rows. Three lanes are tried, the one nearest the halfway+ point first because that is where 0.38.0 put it and for two neighbouring rows it is the+ very same y, then the ones hugging the source and the target, which are the ways out when+ the drop to the middle one has nowhere clear to fall. Each lane is offered both forwards+ (both drops inside the gap) and as a hook (the exit free to travel right of the target's+ start, the return free to travel left of the source's end), because going round the END of+ a bar that lies across the whole span is sometimes the only clear way across;+4. if none of them is clear, the least-bad by how much bar it crosses and then by how far it+ travels, so a hopelessly dense plan gets a short honest line rather than a loop around the+ whole chart. It is never possible to fail to draw an arrow.++Milestone diamonds are obstacles too. A diamond is a rotated square, so its box is not the box+CSS laid out: the 45 degree turn happens about the centre and the `translateX(-50%)` rides+along with it, which puts the visual centre `(w/2)(1 - cos45)` up and left of the border box+and its half-diagonal at `hypot(w,h)/2`. The dashed rule a milestone can draw down the whole+chart is NOT an obstacle, because an arrow crossing a full-height rule is unavoidable and+reads as chrome anyway. Dimmed bars are obstacles: they are still on screen. Collapsed and+hidden rows are not: they have no box.++The clearance is 3px, which is wider than the 1.5px stroke and the 3px a picked arrow swells+to, and far less than the 7px band of empty row above and below every bar, so a lane is still+a lane. Both the row height and the bar height scale with the text size, so the band does too,+and the pad is clamped to a fraction of the smallest band actually measured rather than+trusted to stay at 3.++**The one bug worth recording**, because it was in the collision test rather than in the+routing: the overlap of two intervals, one of which is a single point, is never POSITIVE. A+vertical drop is a segment of no width, so the first cut of `depHit` missed every drop it was+written to catch, and the first probe showed six arrows still crossing while insisting each+route was clear. An axis the segment does not extend along has to be asked whether the point+is inside instead.++**Cost, measured rather than assumed.** `drawDeps` runs on every mousemove of a bar drag, so+this had to be cheap. Same 160-row plan, 72 arrows, timing one synthetic mousemove of a real+drag inside the page: 6.11ms on 0.40.0, 13.67ms on the first cut, 8.88ms as shipped. So the+routing itself costs about 2.8ms for 72 arrows over 150 bars. Two things bought the difference+back: the rows are walked exactly ONCE per redraw (obstacles and lanes come out of the same+pass, and every arrow then works off the numbers), and each arrow first throws away every bar+outside its own y band, since every corner of every candidate sits at y1, y2 or a lane between+them. An arrow between two neighbouring rows therefore measures itself against two bars rather+than a hundred and fifty. Frames through a real 40-step drag of that plan: median 16.7ms, p95+28.7ms, no stutter. So the routing runs in full during a drag, with no simplified route for+the dragged row and nothing deferred to release. It did not need one.++Determinism falls out of the same shape: every input is a layout number, the candidates are+generated in a fixed order, and `depChannel` breaks a tie by taking the smaller x. Asserted+rather than argued: a round trip through the table lens and back, and another through the+three milestone styles, both return every `d` attribute byte for byte, on the small bench and+on the 160-row one.++**Verification.** Two new suites, neither of which reads the app's own routing: the rectangles+are measured off the rendered bars and the routes are sampled off the rendered paths with+`getPointAtLength` at about 2px. Each fixture recomputes the 0.38.0 route from the same four+numbers and asserts it really did cross a bar, so a fixture cannot quietly stop reproducing+the bug it was built for. The route's own two bars are measured with NO padding, because the+arrow is supposed to touch them, and every other bar is grown by 2px, which is less than the+3px the router keeps, so the assertion is a real margin rather than the router's own+arithmetic played back. `v41` (75 assertions) covers the drop through a bar, the hook along a+bar, the wall, the sweep in all three milestone styles, stability, the drag, picking a+rerouted route, Delete twice, and the layer still being inert in the normal lens. `v41big` (23+assertions) is the same correctness question at 160 rows plus the frame budget. The length+bound claimed and asserted: no route is longer than twice the chart width plus the vertical+distance plus its four stubs. Worst detour actually drawn is 5.91x the direct route, and that+is the wall, where going round the end of it is the only clear way across at all.+ ## Build notes, 2026-08-18 (0.40.0, the compact panel says which task it is on, and an armed dependency source is just a selected bar) Two changes, both from Drew, both in the client. `server.js` is untouched, so the running
gantt.html+183−9@@ -3131,6 +3131,11 @@ function sb0(entry){ return entry.row || entry.bar.closest('.bar-row'); } // arrowhead sits. DEP_STUB is the shortest either horizontal run may be, which is // what keeps the two ends readable as horizontal however close the bars are. const DEP_STUB=16, DEP_R=10;+// How much clear air a route keeps between itself and a bar body, and how far past a+// bar's edge a nudged drop lands. Three pixels is wider than the stroke, so the line+// never kisses an edge, and it is far less than the empty band above and below every+// bar, so a lane between two rows is still a lane.+const DEP_PAD=3, DEP_GAP=2; // A polyline with its corners rounded off. Quadratic corners, each radius clamped to // half of the two segments it joins, so a tight elbow degrades to a square one // instead of overshooting into the segment past it. The first and last segments are@@ -3155,19 +3160,188 @@ function roundedPath(pts,rad){ // Either way the first segment leaves the source horizontally and the last enters the // target horizontally from x < x2, so the arrowhead always points right into the // bar's start edge.-function depPath(x1,y1,x2,y2){- const xa=x1+DEP_STUB, xb=x2-DEP_STUB;+//+// Until 0.41.0 both shapes were geometry alone. They knew where the two bars were and+// nothing at all about the ones in between, so a drop could fall straight through the+// body of an unrelated task and a hook's middle run could lie along one, which reads+// as if that bar were part of the dependency.+//+// The chart is rows, and that is the whole trick. Every row carries at most one bar,+// with the same empty band above and below it, so:+// a horizontal run along a row BOUNDARY is clear of every bar by construction;+// a horizontal run at a row's own centre can only meet that row's own bar, which is+// the source behind the exit or the target ahead of the entry;+// so the only thing left to choose is the x of each vertical drop.+// The two shapes are therefore exactly the ones 0.38.0 shipped. What is new is that a+// drop moves to the nearest clear channel when the default x would cross a bar, and+// the hook's middle run is snapped to a row boundary instead of landing wherever the+// midpoint of the two rows happened to fall.++// Every bar body on screen, as a rectangle in the coordinates the arrows are drawn in:+// x off the bar's own offsets, y off its ROW's, which is what the endpoints below are+// already measured with. A collapsed or hidden row has no box and is no obstacle; a+// dimmed one is still on screen, so it still is.+function depRects(){+ const obs=[], lanes=[]; let gap=Infinity;+ items.forEach(r=>{+ const bd=r._bDiv; if(!bd||!bd.offsetHeight)return;+ const top=bd.offsetTop;+ // The boundary below this row, which is where a horizontal run is free.+ lanes.push(top+bd.offsetHeight);+ if(r.id&&barEls[r.id]){+ const b=barEls[r.id].bar; if(!b.offsetWidth)return;+ const y0=top+b.offsetTop, h=b.offsetHeight;+ gap=Math.min(gap,b.offsetTop,bd.offsetHeight-b.offsetTop-h);+ obs.push({id:r.id,x0:b.offsetLeft,x1:b.offsetLeft+b.offsetWidth,y0,y1:y0+h});+ return;+ }+ // A milestone diamond is a rotated square, so its box is not the box CSS laid out:+ // the 45 degree turn happens about the centre and the translateX(-50%) rides along+ // with it. Small, but a route through one still reads as a mistake.+ const d=r._msEl; if(!d||!d.offsetWidth)return;+ const w=d.offsetWidth,h=d.offsetHeight,off=w*Math.SQRT1_2/2,half=Math.hypot(w,h)/2;+ const cx=d.offsetLeft+w/2-off, cy=top+d.offsetTop+h/2-off;+ obs.push({id:null,x0:cx-half,x1:cx+half,y0:cy-half,y1:cy+half});+ });+ // Never claim more clearance than the layout has. The row height and the bar height+ // both scale with the text size, and so does the band between them.+ return {obs,lanes,pad:Math.max(0.5,Math.min(DEP_PAD,gap===Infinity?DEP_PAD:gap*0.4))};+}+// Everything the routing needs, measured once per redraw rather than once per arrow. The+// rows are walked exactly once here: this runs on every mousemove of a bar drag, so the+// DOM is read in one pass and every arrow then works off the numbers.+function depCtx(){+ const m=depRects();+ return {obs:m.obs,pad:m.pad,lanes:m.lanes,xMin:2,xMax:Math.max(2,W-2)};+}+// The route's own two bars are not obstacles at the edges it leaves and arrives on, so+// they are measured without the padding: the exit starts ON the source's right edge and+// the entry ends ON the target's left one. Cross either body anywhere else and it counts.+function depPadOf(o,self,pad){ return self&&o.id&&(o.id===self.from||o.id===self.to)?0:pad; }+// The x spans a vertical run between two y's may not pass through, merged so the search+// walks a handful of intervals rather than every bar.+function depSpans(obs,self,pad,ya,yb){+ const lo=Math.min(ya,yb),hi=Math.max(ya,yb),sp=[];+ for(const o of obs){const q=depPadOf(o,self,pad);+ if(o.y1+q<=lo||o.y0-q>=hi)continue;+ sp.push([o.x0-q,o.x1+q]);}+ sp.sort((a,b)=>a[0]-b[0]);+ const m=[];+ for(const s of sp){const t=m[m.length-1];+ if(t&&s[0]<=t[1]){if(s[1]>t[1])t[1]=s[1];}else m.push([s[0],s[1]]);}+ return m;+}+// The x nearest to `want` inside [lo,hi] that no span covers, or null when the whole+// range is covered. Only the edges of the spans and the two ends are worth trying, and+// a tie goes to the smaller x, so the same layout always routes the same way.+function depChannel(spans,want,lo,hi){+ if(hi<lo)return null;+ const on=v=>spans.some(s=>v>s[0]&&v<s[1]);+ const x=Math.min(Math.max(want,lo),hi);+ if(!on(x))return x;+ let best=null,bd=Infinity;+ const cand=[lo,hi];+ for(const s of spans){cand.push(s[0]-DEP_GAP,s[1]+DEP_GAP);}+ for(const c of cand){+ if(c<lo||c>hi||on(c))continue;+ const d=Math.abs(c-x);+ if(d<bd-1e-9||(best!==null&&d<bd+1e-9&&c<best)){bd=d;best=c;}+ }+ return best;+}+// How much of one segment lies inside a bar. Zero is the whole point of the routing;+// the number itself is only consulted when a plan is dense enough that nothing is+// clear, and then the least-bad route is drawn rather than none at all.+function depHit(obs,self,pad,ax,ay,bx,by){+ const xlo=Math.min(ax,bx),xhi=Math.max(ax,bx),ylo=Math.min(ay,by),yhi=Math.max(ay,by);+ let n=0;+ for(const o of obs){const q=depPadOf(o,self,pad);+ const lx=o.x0-q,hx=o.x1+q,ly=o.y0-q,hy=o.y1+q;+ const ox=Math.min(xhi,hx)-Math.max(xlo,lx), oy=Math.min(yhi,hy)-Math.max(ylo,ly);+ // A run with no width is still inside a bar when its x is. The overlap of two+ // intervals, one of which is a single point, is never POSITIVE, so an axis the+ // segment does not extend along has to be asked the other question.+ const onX=xhi>xlo?ox>0:(xlo>lx&&xlo<hx);+ const onY=yhi>ylo?oy>0:(ylo>ly&&ylo<hy);+ if(onX&&onY)n+=Math.max(ox,oy,0.5);}+ return n;+}+// The corner points of the route, in order. The first candidate tried is the 0.38.0+// one, so an arrow with nothing in its way is drawn exactly where this app has drawn+// it since 0.38.0 and a redraw that changes nothing changes nothing.+function depPts(x1,y1,x2,y2,ctx,self){+ const xa=x1+DEP_STUB, xb=x2-DEP_STUB, pad=ctx.pad;+ const lo=Math.min(y1,y2),hi=Math.max(y1,y2);+ // Every corner of every route below sits at y1, y2, or a lane between them, so a bar+ // outside that band cannot be in the way of any of them. Throwing those away first is+ // what keeps this affordable on a plan of a hundred and sixty rows: an arrow between+ // two neighbouring rows then measures itself against two bars rather than all of them.+ const obs=ctx.obs.filter(o=>o.y1+pad>lo&&o.y0-pad<hi);+ const want=Math.min(Math.max(x1+(x2-x1)*0.5,xa),xb);+ const tried=[];+ const cost=pts=>{let n=0;for(let i=1;i<pts.length;i++)n+=depHit(obs,self,pad,pts[i-1][0],pts[i-1][1],pts[i][0],pts[i][1]);return n;};+ const len=pts=>{let n=0;for(let i=1;i<pts.length;i++)n+=Math.abs(pts[i][0]-pts[i-1][0])+Math.abs(pts[i][1]-pts[i-1][1]);return n;};+ const keep=pts=>{const c=cost(pts);tried.push({pts,c,l:len(pts)});return c===0;}; if(xb>=xa){- const xd=Math.min(Math.max(x1+(x2-x1)*0.5,xa),xb);- return roundedPath([[x1,y1],[xd,y1],[xd,y2],[x2,y2]],DEP_R);+ // A Z that crosses nothing is not merely good enough, it is the SHORTEST route there+ // is: it never turns back, so it walks the bare distance between the two bars. Both+ // of these therefore end the search, and the first of them is the 0.38.0 route+ // itself, which is why an uncluttered plan looks exactly as it did.+ if(keep([[x1,y1],[want,y1],[want,y2],[x2,y2]]))return tried[0].pts;+ const xd=depChannel(depSpans(obs,self,pad,y1,y2),want,xa,xb);+ if(xd!==null&&keep([[x1,y1],[xd,y1],[xd,y2],[x2,y2]]))return tried[tried.length-1].pts;+ }+ // The middle run goes in a lane BETWEEN the two rows, where no bar can be. The lane+ // nearest the halfway point is tried first because that is where 0.38.0 put it and+ // for two neighbouring rows it is the very same y; the ones hugging the source and+ // the target are the ways out when the drop to it has nowhere clear to fall.+ const lanes=ctx.lanes.filter(y=>y>lo+0.5&&y<hi-0.5);+ const picks=[];+ if(lanes.length){+ const mid=(y1+y2)/2;+ let near=lanes[0];+ for(const y of lanes)if(Math.abs(y-mid)<Math.abs(near-mid))near=y;+ for(const y of [near,y2>y1?lanes[0]:lanes[lanes.length-1],y2>y1?lanes[lanes.length-1]:lanes[0]])+ if(!picks.includes(y))picks.push(y);+ }+ const S=(ym,xE,xM)=>{if(xE!==null&&xM!==null)keep([[x1,y1],[xE,y1],[xE,ym],[xM,ym],[xM,y2],[x2,y2]]);};+ for(const ym of picks){+ // The two halves of the drop, measured once and used by both shapes below.+ const st=depSpans(obs,self,pad,y1,ym), sb=depSpans(obs,self,pad,ym,y2);+ // Forwards: out, down to the lane, along it, down again, in. Both drops stay in the+ // gap between the two bars, so this is the Z with its drop broken in two.+ if(xb>=xa){+ const xE=depChannel(st,xa,xa,xb);+ S(ym,xE,xE===null?null:depChannel(sb,xb,xE,xb));+ }+ // The hook. Its exit may travel further RIGHT than the target's start and its return+ // further LEFT than the source's end, which is the only way round a bar that lies+ // across the whole span between the two rows. It is the shape this app has drawn+ // since 0.38.0 whenever a target starts before its source ends, so nothing new+ // appears on screen here; it is only offered to more pairs than it used to be.+ const xE=depChannel(st,xa,xa,Math.max(xa,ctx.xMax));+ const cap=Math.min(xb,(xE===null?xa:xE)-DEP_STUB);+ S(ym,xE,xE===null?null:depChannel(sb,cap,Math.min(ctx.xMin,cap),cap)); }- // Run back at least one more stub past the exit, so the lane is wide enough to- // read even when the two bars begin and end on the very same day.- const back=Math.min(xb,xa-DEP_STUB), ym=(y1+y2)/2;- return roundedPath([[x1,y1],[xa,y1],[xa,ym],[back,ym],[back,y2],[x2,y2]],DEP_R);+ // The shortest of the routes that cross nothing, and if none of them does, the+ // least-bad by how much bar it crosses, so a hopelessly dense plan still gets a short+ // honest line rather than a loop around the whole chart. If no shape produced a+ // candidate at all, the 0.38.0 route, which always produces one.+ if(!tried.length){+ const back=Math.min(xb,xa-DEP_STUB), ym=(y1+y2)/2;+ return xb>=xa?[[x1,y1],[want,y1],[want,y2],[x2,y2]]+ :[[x1,y1],[xa,y1],[xa,ym],[back,ym],[back,y2],[x2,y2]];+ }+ tried.sort((a,b)=>a.c-b.c||a.l-b.l);+ return tried[0].pts;+}+function depPath(x1,y1,x2,y2,ctx,self){+ return roundedPath(depPts(x1,y1,x2,y2,ctx||depCtx(),self),DEP_R); } function drawDeps(){ depSvg.innerHTML='';let maxY=0;+ // One measurement of the bars for the whole layer, not one per arrow.+ const ctx=depCtx(); items.forEach(r=>{if(!r.deps||!r.id)return;const tgt=barEls[r.id];if(!tgt)return; r.deps.forEach(depId=>{const src=barEls[depId];if(!src)return; const sItem=items[src.idx];@@ -3184,7 +3358,7 @@ function drawDeps(){ // rather than extra nodes: the layer is redrawn wholesale on every geometry // change, so a pick has to be re-applied from state afterwards, and one path // plus one head per edge is what every other reader of this layer counts on.- const p=document.createElementNS('http://www.w3.org/2000/svg','path');p.setAttribute('d',depPath(x1,y1,x2,y2));+ const p=document.createElementNS('http://www.w3.org/2000/svg','path');p.setAttribute('d',depPath(x1,y1,x2,y2,ctx,{from:depId,to:r.id})); p.setAttribute('data-from',depId);p.setAttribute('data-to',r.id);depSvg.appendChild(p); const a=document.createElementNS('http://www.w3.org/2000/svg','polygon');a.setAttribute('points',`${x2},${y2} ${x2-6},${y2-3} ${x2-6},${y2+3}`); a.setAttribute('data-from',depId);a.setAttribute('data-to',r.id);depSvg.appendChild(a);
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.40.0",+ "version": "0.41.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {
page.json+2−2@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.40.0",+ "version": "0.41.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {@@ -63,4 +63,4 @@ "name": "Drew Owens", "email": "[email protected]" }-}+}