Phase C1B: Fix Incomplete Timeline Plot Thread Improvements
This commit is contained in:
parent
5ece8d9451
commit
377d0540ca
@ -115,7 +115,7 @@
|
||||
var warningText = $"{warning.WarningTypeName} {warning.Message} {warning.Details}".ToLowerInvariant();
|
||||
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"))
|
||||
{
|
||||
@ -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">
|
||||
<svg class="plot-lane-svg-overlay" data-plot-line-svg aria-hidden="true"></svg>
|
||||
<div class="lane-board-heading plot-lane-board-heading">
|
||||
<button type="button" data-collapse-toggle="plot-lines">Plot line lanes</button>
|
||||
<help-icon key="timeline.plotThreadLanes" mode="inline" />
|
||||
<span class="timeline-heading-with-help">
|
||||
<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>
|
||||
<help-icon key="timeline.threadHealth" mode="inline" />
|
||||
</div>
|
||||
<div data-collapse-body="plot-lines">
|
||||
@foreach (var group in Model.PlotLanes.GroupBy(x => x.PlotLine.PlotLineTypeName).OrderBy(x => x.Key))
|
||||
|
||||
@ -1077,11 +1077,16 @@ body.timeline-inspector-resizing {
|
||||
|
||||
.plot-line-svg-path {
|
||||
fill: none;
|
||||
stroke-width: 4;
|
||||
stroke-width: 3.5;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.plot-line-svg-segment-dormant {
|
||||
stroke-dasharray: 9 8;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.plot-line-svg-connector {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
@ -1089,11 +1094,6 @@ body.timeline-inspector-resizing {
|
||||
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-questionable {
|
||||
stroke: #8c9491;
|
||||
@ -1102,7 +1102,6 @@ body.timeline-inspector-resizing {
|
||||
|
||||
.plot-line-svg-path.plotline-state-abandoned {
|
||||
stroke: #9b2f36;
|
||||
stroke-dasharray: 5 7;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@ -1126,6 +1125,10 @@ body.timeline-inspector-resizing {
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.plotline-health-marker {
|
||||
pointer-events: visiblePainted;
|
||||
}
|
||||
|
||||
.plotline-health-marker line,
|
||||
.plotline-health-marker text {
|
||||
stroke: #5e4710;
|
||||
@ -1138,6 +1141,14 @@ body.timeline-inspector-resizing {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
stroke-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.plotline-health-count {
|
||||
fill: #ffffff;
|
||||
stroke: #8a6712;
|
||||
stroke-width: 2;
|
||||
paint-order: stroke;
|
||||
}
|
||||
|
||||
.plotline-health-marker-dangling path,
|
||||
@ -1217,6 +1228,13 @@ body.timeline-inspector-resizing {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-heading-with-help {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plot-lane-row {
|
||||
display: grid;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 46px;
|
||||
border: 1px dashed #d8ddd8;
|
||||
@ -1264,29 +1284,35 @@ body.timeline-inspector-resizing {
|
||||
}
|
||||
|
||||
.thread-marker {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
min-width: 26px;
|
||||
height: 24px;
|
||||
border: 1px solid color-mix(in srgb, var(--plot-colour), #000000 18%);
|
||||
gap: 4px;
|
||||
min-width: 34px;
|
||||
height: 30px;
|
||||
border: 2px solid color-mix(in srgb, var(--plot-colour), #000000 20%);
|
||||
border-radius: 999px;
|
||||
padding: 0 7px;
|
||||
padding: 0 8px;
|
||||
color: var(--plotline-ink);
|
||||
background: color-mix(in srgb, var(--plot-colour), #ffffff 78%);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
background: color-mix(in srgb, var(--plot-colour), #ffffff 66%);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 3px 8px rgba(30, 37, 43, 0.14);
|
||||
}
|
||||
|
||||
.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);
|
||||
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 {
|
||||
@ -2454,11 +2480,34 @@ body.timeline-inspector-resizing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 14px;
|
||||
font-size: 0.68rem;
|
||||
min-width: 16px;
|
||||
font-size: 0.78rem;
|
||||
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 {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
|
||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
@ -1616,9 +1616,14 @@
|
||||
}
|
||||
|
||||
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 {
|
||||
x: slotRect.left - boardRect.left + slotRect.width / 2,
|
||||
y: rowCenterY(row),
|
||||
x: markerCenterX,
|
||||
y: markerCenterY,
|
||||
sceneX: slotRect.left - boardRect.left + slotRect.width / 2,
|
||||
radius: Math.max(markerRect.width, markerRect.height) / 2,
|
||||
sceneIndex: 0,
|
||||
type: (marker.dataset.plotEventType || "Progress").toLowerCase(),
|
||||
targetPlotLineId: marker.dataset.targetPlotLineId || "",
|
||||
@ -1653,7 +1658,7 @@
|
||||
.filter(Boolean)
|
||||
.map((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),
|
||||
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) => [
|
||||
`Warning: ${warning.label}`,
|
||||
`Reason: ${warning.reason}`,
|
||||
@ -1763,26 +1706,29 @@
|
||||
].join("\n")).join("\n\n");
|
||||
|
||||
const drawHealthMarker = (point, warnings) => {
|
||||
const offsetX = 14;
|
||||
const offsetX = Math.max(18, (point.radius || 12) + 8);
|
||||
const primaryKind = warnings[0]?.kind || "warning";
|
||||
const group = makeSvg("g", { class: `plotline-health-marker plotline-health-marker-${primaryKind}` });
|
||||
const title = makeSvg("title");
|
||||
title.textContent = healthWarningTooltip(warnings);
|
||||
group.append(title);
|
||||
|
||||
const markerX = point.x + offsetX;
|
||||
const markerY = point.y - 13;
|
||||
if (primaryKind === "questionable") {
|
||||
group.append(makeSvg("circle", { cx: point.x + offsetX, cy: point.y - 10, r: 7 }));
|
||||
const text = makeSvg("text", { x: point.x + offsetX, y: point.y - 6, "text-anchor": "middle" });
|
||||
group.append(makeSvg("circle", { cx: markerX, cy: markerY, r: 9 }));
|
||||
const text = makeSvg("text", { x: markerX, y: markerY + 4, "text-anchor": "middle" });
|
||||
text.textContent = warnings.length > 1 ? String(warnings.length) : "?";
|
||||
group.append(text);
|
||||
} else {
|
||||
group.append(makeSvg("circle", { cx: markerX, cy: markerY, r: 10 }));
|
||||
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("circle", { cx: point.x + offsetX, cy: point.y - 5.6, r: 1.3 }));
|
||||
group.append(makeSvg("line", { x1: markerX, y1: markerY - 2, x2: markerX, y2: markerY + 2.5 }));
|
||||
group.append(makeSvg("circle", { cx: markerX, cy: markerY + 5.3, r: 1.2 }));
|
||||
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);
|
||||
group.append(text);
|
||||
}
|
||||
@ -1801,11 +1747,13 @@
|
||||
|
||||
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 targetX = source.x + flowX;
|
||||
const midX = source.x + flowX / 2;
|
||||
const sourceRadius = Math.max(8, source.radius || 12);
|
||||
const startX = source.x + sourceRadius;
|
||||
const targetX = startX + flowX;
|
||||
const midX = startX + flowX / 2;
|
||||
svg.append(makeSvg("path", {
|
||||
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
|
||||
}));
|
||||
};
|
||||
@ -1842,14 +1790,28 @@
|
||||
: first.x;
|
||||
const endX = terminatingPoint ? terminatingPoint.x : last.x;
|
||||
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", {
|
||||
class: `plot-line-svg-path plotline-state-${state}`,
|
||||
d: `M ${startX} ${first.y} L ${endX} ${first.y}`,
|
||||
class: `plot-line-svg-path plot-line-svg-segment ${isDormantGap ? "plot-line-svg-segment-dormant" : ""} plotline-state-${state}`,
|
||||
d: `M ${point.x} ${point.y} L ${nextPoint.x} ${nextPoint.y}`,
|
||||
stroke: colour
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -1869,12 +1831,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
rowData.forEach(({ points, colour, state }) => {
|
||||
points.forEach((point) => {
|
||||
drawNode(point, colour, state);
|
||||
});
|
||||
});
|
||||
|
||||
rowData.forEach((item) => {
|
||||
const markerPoint = item.lineEndPoint || item.points[item.points.length - 1];
|
||||
const healthWarnings = [];
|
||||
|
||||
2
PlotLine/wwwroot/js/site.min.js
vendored
2
PlotLine/wwwroot/js/site.min.js
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user