Phase C1B: Fix Incomplete Timeline Plot Thread Improvements

This commit is contained in:
Nick Beckley 2026-06-19 21:29:36 +01:00
parent 5ece8d9451
commit 377d0540ca
5 changed files with 119 additions and 112 deletions

View File

@ -115,7 +115,7 @@
var warningText = $"{warning.WarningTypeName} {warning.Message} {warning.Details}".ToLowerInvariant(); var warningText = $"{warning.WarningTypeName} {warning.Message} {warning.Details}".ToLowerInvariant();
if (warningText.Contains("dormant") || warningText.Contains("no activity") || warningText.Contains("neglected")) if (warningText.Contains("dormant") || warningText.Contains("no activity") || warningText.Contains("neglected"))
{ {
return "Add a Progress, Reveal or Resolve event, or confirm the thread is intentionally quiet."; return "Add a Progress, Reveal, Twist, Resolve or Abandon event, or confirm the thread is intentionally quiet.";
} }
if (warningText.Contains("unresolved") || warningText.Contains("missing closure") || warningText.Contains("closure")) if (warningText.Contains("unresolved") || warningText.Contains("missing closure") || warningText.Contains("closure"))
{ {
@ -941,10 +941,12 @@ else
<div class="plot-lane-board" data-timeline-section="plot-lines" data-plot-line-svg-board style="--scene-count:@timelineColumns.Count"> <div class="plot-lane-board" data-timeline-section="plot-lines" data-plot-line-svg-board style="--scene-count:@timelineColumns.Count">
<svg class="plot-lane-svg-overlay" data-plot-line-svg aria-hidden="true"></svg> <svg class="plot-lane-svg-overlay" data-plot-line-svg aria-hidden="true"></svg>
<div class="lane-board-heading plot-lane-board-heading"> <div class="lane-board-heading plot-lane-board-heading">
<button type="button" data-collapse-toggle="plot-lines">Plot line lanes</button> <span class="timeline-heading-with-help">
<help-icon key="timeline.plotThreadLanes" mode="inline" /> <button type="button" data-collapse-toggle="plot-lines">Plot line lanes</button>
<help-icon key="timeline.plotThreadLanes" mode="inline" />
<help-icon key="timeline.threadHealth" mode="inline" />
</span>
<small>Thread event markers by scene</small> <small>Thread event markers by scene</small>
<help-icon key="timeline.threadHealth" mode="inline" />
</div> </div>
<div data-collapse-body="plot-lines"> <div data-collapse-body="plot-lines">
@foreach (var group in Model.PlotLanes.GroupBy(x => x.PlotLine.PlotLineTypeName).OrderBy(x => x.Key)) @foreach (var group in Model.PlotLanes.GroupBy(x => x.PlotLine.PlotLineTypeName).OrderBy(x => x.Key))

View File

@ -1077,11 +1077,16 @@ body.timeline-inspector-resizing {
.plot-line-svg-path { .plot-line-svg-path {
fill: none; fill: none;
stroke-width: 4; stroke-width: 3.5;
stroke-linecap: round; stroke-linecap: round;
opacity: 0.72; opacity: 0.72;
} }
.plot-line-svg-segment-dormant {
stroke-dasharray: 9 8;
opacity: 0.48;
}
.plot-line-svg-connector { .plot-line-svg-connector {
fill: none; fill: none;
stroke-width: 3; stroke-width: 3;
@ -1089,11 +1094,6 @@ body.timeline-inspector-resizing {
opacity: 0.58; opacity: 0.58;
} }
.plot-line-svg-path.plotline-state-neglected {
stroke-dasharray: 9 8;
opacity: 0.44;
}
.plot-line-svg-path.plotline-state-resolved, .plot-line-svg-path.plotline-state-resolved,
.plot-line-svg-path.plotline-state-questionable { .plot-line-svg-path.plotline-state-questionable {
stroke: #8c9491; stroke: #8c9491;
@ -1102,7 +1102,6 @@ body.timeline-inspector-resizing {
.plot-line-svg-path.plotline-state-abandoned { .plot-line-svg-path.plotline-state-abandoned {
stroke: #9b2f36; stroke: #9b2f36;
stroke-dasharray: 5 7;
opacity: 0.5; opacity: 0.5;
} }
@ -1126,6 +1125,10 @@ body.timeline-inspector-resizing {
stroke-width: 1.5; stroke-width: 1.5;
} }
.plotline-health-marker {
pointer-events: visiblePainted;
}
.plotline-health-marker line, .plotline-health-marker line,
.plotline-health-marker text { .plotline-health-marker text {
stroke: #5e4710; stroke: #5e4710;
@ -1138,6 +1141,14 @@ body.timeline-inspector-resizing {
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
stroke-width: 0; stroke-width: 0;
pointer-events: none;
}
.plotline-health-count {
fill: #ffffff;
stroke: #8a6712;
stroke-width: 2;
paint-order: stroke;
} }
.plotline-health-marker-dangling path, .plotline-health-marker-dangling path,
@ -1217,6 +1228,13 @@ body.timeline-inspector-resizing {
white-space: nowrap; white-space: nowrap;
} }
.timeline-heading-with-help {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.plot-lane-row { .plot-lane-row {
display: grid; display: grid;
grid-template-columns: var(--timeline-label-width) repeat(var(--scene-count), var(--timeline-scene-width)); grid-template-columns: var(--timeline-label-width) repeat(var(--scene-count), var(--timeline-scene-width));
@ -1254,7 +1272,9 @@ body.timeline-inspector-resizing {
.plot-lane-slot { .plot-lane-slot {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-content: flex-start; align-content: center;
align-items: center;
justify-content: center;
gap: 5px; gap: 5px;
min-height: 46px; min-height: 46px;
border: 1px dashed #d8ddd8; border: 1px dashed #d8ddd8;
@ -1264,29 +1284,35 @@ body.timeline-inspector-resizing {
} }
.thread-marker { .thread-marker {
position: relative;
z-index: 5;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 3px; gap: 4px;
min-width: 26px; min-width: 34px;
height: 24px; height: 30px;
border: 1px solid color-mix(in srgb, var(--plot-colour), #000000 18%); border: 2px solid color-mix(in srgb, var(--plot-colour), #000000 20%);
border-radius: 999px; border-radius: 999px;
padding: 0 7px; padding: 0 8px;
color: var(--plotline-ink); color: var(--plotline-ink);
background: color-mix(in srgb, var(--plot-colour), #ffffff 78%); background: color-mix(in srgb, var(--plot-colour), #ffffff 66%);
font-size: 0.72rem; font-size: 0.74rem;
font-weight: 800; font-weight: 900;
text-decoration: none; text-decoration: none;
box-shadow: 0 3px 8px rgba(30, 37, 43, 0.14);
} }
.plot-lane-board [data-plot-event-node] { .plot-lane-board [data-plot-event-node] {
opacity: 0; opacity: 1;
} }
.thread-marker:hover { .thread-marker:hover,
.thread-marker:focus-visible {
color: var(--plotline-ink); color: var(--plotline-ink);
background: color-mix(in srgb, var(--plot-colour), #ffffff 58%); background: color-mix(in srgb, var(--plot-colour), #ffffff 48%);
outline: 3px solid rgba(47, 111, 99, 0.22);
outline-offset: 2px;
} }
.thread-marker.resolved { .thread-marker.resolved {
@ -2454,11 +2480,34 @@ body.timeline-inspector-resizing {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 14px; min-width: 16px;
font-size: 0.68rem; font-size: 0.78rem;
font-weight: 900; font-weight: 900;
} }
.thread-marker-type-start .thread-marker-icon,
.thread-marker-type-resolve .thread-marker-icon,
.thread-marker-type-abandon .thread-marker-icon,
.thread-marker-type-twist .thread-marker-icon {
font-size: 0.9rem;
}
.thread-marker-type-start {
border-radius: 8px 999px 999px 8px;
}
.thread-marker-type-twist {
border-color: #b8762b;
background: #fff4dc;
}
.thread-marker-type-branch,
.thread-marker-type-split,
.thread-marker-type-merge {
border-color: #6750a4;
background: #f0ebff;
}
.thread-marker-type-reveal .thread-marker-icon { .thread-marker-type-reveal .thread-marker-icon {
border: 1px solid currentColor; border: 1px solid currentColor;
border-radius: 999px; border-radius: 999px;

File diff suppressed because one or more lines are too long

View File

@ -1616,9 +1616,14 @@
} }
const slotRect = slot.getBoundingClientRect(); const slotRect = slot.getBoundingClientRect();
const markerRect = marker.getBoundingClientRect();
const markerCenterX = markerRect.left - boardRect.left + markerRect.width / 2;
const markerCenterY = markerRect.top - boardRect.top + markerRect.height / 2;
return { return {
x: slotRect.left - boardRect.left + slotRect.width / 2, x: markerCenterX,
y: rowCenterY(row), y: markerCenterY,
sceneX: slotRect.left - boardRect.left + slotRect.width / 2,
radius: Math.max(markerRect.width, markerRect.height) / 2,
sceneIndex: 0, sceneIndex: 0,
type: (marker.dataset.plotEventType || "Progress").toLowerCase(), type: (marker.dataset.plotEventType || "Progress").toLowerCase(),
targetPlotLineId: marker.dataset.targetPlotLineId || "", targetPlotLineId: marker.dataset.targetPlotLineId || "",
@ -1653,7 +1658,7 @@
.filter(Boolean) .filter(Boolean)
.map((point) => ({ .map((point) => ({
...point, ...point,
sceneIndex: Math.max(0, sceneXs.findIndex((x) => x === Math.round(point.x))) sceneIndex: Math.max(0, sceneXs.findIndex((x) => x === Math.round(point.sceneX)))
})) }))
.sort((a, b) => a.x - b.x), .sort((a, b) => a.x - b.x),
incomingStartXs: [] incomingStartXs: []
@ -1694,68 +1699,6 @@
}; };
}; };
const eventColour = (point, colour) => ({
reveal: "#33759b",
twist: "#b8762b",
branch: "#6750a4",
split: "#6750a4",
merge: "#6750a4",
resolve: "#4d7654",
abandon: "#963f36"
}[point.type] || colour);
const addNodeText = (group, point, text, fill = "#ffffff") => {
const label = makeSvg("text", {
x: point.x,
y: point.y + 4,
"text-anchor": "middle",
class: "plot-event-svg-label",
fill
});
label.textContent = text;
group.append(label);
};
const drawNode = (point, colour, state) => {
const group = makeSvg("g", { class: `plot-event-svg-node plot-event-svg-node-${point.type} plotline-state-${state}` });
const fill = eventColour(point, colour);
const isStructural = point.type === "branch" || point.type === "split" || point.type === "merge";
if (point.type === "start") {
group.append(makeSvg("circle", { cx: point.x, cy: point.y, r: 9.5, fill }));
addNodeText(group, point, ">");
} else if (point.type === "twist") {
group.append(makeSvg("path", {
d: `M ${point.x} ${point.y - 9} L ${point.x + 9} ${point.y} L ${point.x} ${point.y + 9} L ${point.x - 9} ${point.y} Z`,
fill
}));
addNodeText(group, point, "!");
} else if (point.type === "abandon") {
group.append(makeSvg("circle", { cx: point.x, cy: point.y, r: 9.5, fill }));
group.append(makeSvg("line", { x1: point.x - 5.5, y1: point.y - 5.5, x2: point.x + 5.5, y2: point.y + 5.5 }));
group.append(makeSvg("line", { x1: point.x + 5.5, y1: point.y - 5.5, x2: point.x - 5.5, y2: point.y + 5.5 }));
} else if (point.type === "reveal") {
group.append(makeSvg("ellipse", { cx: point.x, cy: point.y, rx: 11, ry: 7.5, fill }));
group.append(makeSvg("circle", { cx: point.x, cy: point.y, r: 3.2, fill: "#ffffff" }));
} else {
group.append(makeSvg("circle", { cx: point.x, cy: point.y, r: isStructural ? 9.5 : 8.2, fill }));
if (point.type === "resolve") {
group.append(makeSvg("path", {
d: `M ${point.x - 5} ${point.y} L ${point.x - 1} ${point.y + 4} L ${point.x + 6} ${point.y - 5}`,
fill: "none",
stroke: "#ffffff"
}));
}
if (isStructural) {
group.append(makeSvg("circle", { cx: point.x, cy: point.y, r: 13, fill: "none", stroke: fill }));
addNodeText(group, point, point.type === "split" ? "Y" : point.type === "merge" ? "M" : "B");
} else if (point.type === "progress") {
addNodeText(group, point, "+");
}
}
svg.append(group);
};
const healthWarningTooltip = (warnings) => warnings.map((warning) => [ const healthWarningTooltip = (warnings) => warnings.map((warning) => [
`Warning: ${warning.label}`, `Warning: ${warning.label}`,
`Reason: ${warning.reason}`, `Reason: ${warning.reason}`,
@ -1763,26 +1706,29 @@
].join("\n")).join("\n\n"); ].join("\n")).join("\n\n");
const drawHealthMarker = (point, warnings) => { const drawHealthMarker = (point, warnings) => {
const offsetX = 14; const offsetX = Math.max(18, (point.radius || 12) + 8);
const primaryKind = warnings[0]?.kind || "warning"; const primaryKind = warnings[0]?.kind || "warning";
const group = makeSvg("g", { class: `plotline-health-marker plotline-health-marker-${primaryKind}` }); const group = makeSvg("g", { class: `plotline-health-marker plotline-health-marker-${primaryKind}` });
const title = makeSvg("title"); const title = makeSvg("title");
title.textContent = healthWarningTooltip(warnings); title.textContent = healthWarningTooltip(warnings);
group.append(title); group.append(title);
const markerX = point.x + offsetX;
const markerY = point.y - 13;
if (primaryKind === "questionable") { if (primaryKind === "questionable") {
group.append(makeSvg("circle", { cx: point.x + offsetX, cy: point.y - 10, r: 7 })); group.append(makeSvg("circle", { cx: markerX, cy: markerY, r: 9 }));
const text = makeSvg("text", { x: point.x + offsetX, y: point.y - 6, "text-anchor": "middle" }); const text = makeSvg("text", { x: markerX, y: markerY + 4, "text-anchor": "middle" });
text.textContent = warnings.length > 1 ? String(warnings.length) : "?"; text.textContent = warnings.length > 1 ? String(warnings.length) : "?";
group.append(text); group.append(text);
} else { } else {
group.append(makeSvg("circle", { cx: markerX, cy: markerY, r: 10 }));
group.append(makeSvg("path", { group.append(makeSvg("path", {
d: `M ${point.x + offsetX} ${point.y - 18} L ${point.x + offsetX + 8} ${point.y - 3} L ${point.x + offsetX - 8} ${point.y - 3} Z` d: `M ${markerX} ${markerY - 6} L ${markerX + 6} ${markerY + 5} L ${markerX - 6} ${markerY + 5} Z`
})); }));
group.append(makeSvg("line", { x1: point.x + offsetX, y1: point.y - 13, x2: point.x + offsetX, y2: point.y - 8 })); group.append(makeSvg("line", { x1: markerX, y1: markerY - 2, x2: markerX, y2: markerY + 2.5 }));
group.append(makeSvg("circle", { cx: point.x + offsetX, cy: point.y - 5.6, r: 1.3 })); group.append(makeSvg("circle", { cx: markerX, cy: markerY + 5.3, r: 1.2 }));
if (warnings.length > 1) { if (warnings.length > 1) {
const text = makeSvg("text", { x: point.x + offsetX, y: point.y - 6, "text-anchor": "middle" }); const text = makeSvg("text", { x: markerX + 11, y: markerY - 6, "text-anchor": "middle", class: "plotline-health-count" });
text.textContent = String(warnings.length); text.textContent = String(warnings.length);
group.append(text); group.append(text);
} }
@ -1801,11 +1747,13 @@
const target = { x: source.x, y: rowCenterY(targetRow) }; const target = { x: source.x, y: rowCenterY(targetRow) };
const flowX = Math.max(28, Math.min(46, Math.abs(target.y - source.y) * 0.28)); const flowX = Math.max(28, Math.min(46, Math.abs(target.y - source.y) * 0.28));
const targetX = source.x + flowX; const sourceRadius = Math.max(8, source.radius || 12);
const midX = source.x + flowX / 2; const startX = source.x + sourceRadius;
const targetX = startX + flowX;
const midX = startX + flowX / 2;
svg.append(makeSvg("path", { svg.append(makeSvg("path", {
class: `plot-line-svg-connector plot-line-svg-connector-${type}`, class: `plot-line-svg-connector plot-line-svg-connector-${type}`,
d: `M ${source.x} ${source.y} C ${midX} ${source.y}, ${midX} ${target.y}, ${targetX} ${target.y}`, d: `M ${startX} ${source.y} C ${midX} ${source.y}, ${midX} ${target.y}, ${targetX} ${target.y}`,
stroke: colour stroke: colour
})); }));
}; };
@ -1842,14 +1790,28 @@
: first.x; : first.x;
const endX = terminatingPoint ? terminatingPoint.x : last.x; const endX = terminatingPoint ? terminatingPoint.x : last.x;
item.lineEndPoint = { x: endX, y: first.y }; item.lineEndPoint = { x: endX, y: first.y };
const sortedPoints = [...points].sort((a, b) => a.x - b.x);
const terminatingIndex = terminatingPoint ? sortedPoints.indexOf(terminatingPoint) : -1;
const linePoints = terminatingIndex >= 0 ? sortedPoints.slice(0, terminatingIndex + 1) : sortedPoints;
if (incomingStartX !== null && incomingStartX < linePoints[0].x) {
linePoints.unshift({ ...linePoints[0], x: incomingStartX, sceneIndex: Math.max(0, linePoints[0].sceneIndex - 1) });
}
if (endX > startX) { // Only the segment between two events is dormant when their scene gap crosses the threshold.
// Thread-level resolved/abandoned/neglected state must not make the entire historical line dotted.
const dormantGapThreshold = 10;
linePoints.forEach((point, index) => {
const nextPoint = linePoints[index + 1];
if (!nextPoint || nextPoint.x <= point.x) {
return;
}
const isDormantGap = Math.abs(nextPoint.sceneIndex - point.sceneIndex) >= dormantGapThreshold;
svg.append(makeSvg("path", { svg.append(makeSvg("path", {
class: `plot-line-svg-path plotline-state-${state}`, class: `plot-line-svg-path plot-line-svg-segment ${isDormantGap ? "plot-line-svg-segment-dormant" : ""} plotline-state-${state}`,
d: `M ${startX} ${first.y} L ${endX} ${first.y}`, d: `M ${point.x} ${point.y} L ${nextPoint.x} ${nextPoint.y}`,
stroke: colour stroke: colour
})); }));
} });
} }
}); });
@ -1869,12 +1831,6 @@
}); });
}); });
rowData.forEach(({ points, colour, state }) => {
points.forEach((point) => {
drawNode(point, colour, state);
});
});
rowData.forEach((item) => { rowData.forEach((item) => {
const markerPoint = item.lineEndPoint || item.points[item.points.length - 1]; const markerPoint = item.lineEndPoint || item.points[item.points.length - 1];
const healthWarnings = []; const healthWarnings = [];

File diff suppressed because one or more lines are too long