Phase C1: Plot Thread Readability & Warning Clarity

This commit is contained in:
Nick Beckley 2026-06-19 21:22:55 +01:00
parent 783fb916f8
commit 5ece8d9451
8 changed files with 280 additions and 48 deletions

View File

@ -590,6 +590,8 @@ public sealed class ThreadEvent
public int EventTypeID { get; set; }
public string EventTypeName { get; set; } = string.Empty;
public string MarkerText { get; set; } = string.Empty;
public string ThreadStatusName { get; set; } = string.Empty;
public int Importance { get; set; }
public int PlotEventTypeID { get; set; } = 2;
public string PlotEventTypeName { get; set; } = "Progress";
public int? TargetPlotLineID { get; set; }

View File

@ -1925,9 +1925,14 @@ public sealed class TimelineService(
var scenesByChapter = timeline.Scenes.GroupBy(x => x.ChapterID).ToDictionary(x => x.Key, x => x.ToList());
var purposeLabelsByScene = timeline.PurposeLabels.GroupBy(x => x.SceneID).ToDictionary(x => x.Key, x => x.OrderBy(p => p.SortOrder).ToList());
var orderedScenes = timeline.Scenes.ToList();
var warningCounts = needsWarnings
? (await TimeTimelineLoadAsync("dbo.ContinuityWarning_SceneCounts", () => warnings.GetSceneCountsAsync(projectId, bookId), projectId, bookId)).ToDictionary(x => x.SceneID, x => x.WarningCount)
: new Dictionary<int, int>();
var timelineWarnings = needsWarnings
? await TimeTimelineLoadAsync("dbo.ContinuityWarning_List", () => warnings.ListAsync(projectId, bookId, null, filter.WarningTypeID, filter.WarningSeverityID, null, filter.IncludeDismissedWarnings, filter.IncludeIntentionalWarnings), projectId, bookId)
: [];
var warningsByScene = timelineWarnings
.Where(x => x.SceneID.HasValue)
.GroupBy(x => x.SceneID!.Value)
.ToDictionary(x => x.Key, x => (IReadOnlyList<ContinuityWarning>)x.ToList());
var warningCounts = warningsByScene.ToDictionary(x => x.Key, x => x.Value.Count);
var dependencyCounts = (await TimeTimelineLoadAsync("dbo.SceneDependency_Counts", () => sceneDependencies.GetCountsAsync(projectId, bookId), projectId, bookId)).ToDictionary(x => x.SceneID, x => x.DependencyCount);
foreach (var scene in orderedScenes)
@ -1943,6 +1948,15 @@ public sealed class TimelineService(
(IReadOnlyList<Character> Characters, IReadOnlyList<SceneCharacter> Appearances) characterTimeline = needsCharacterTimelineData
? await TimeTimelineLoadAsync("dbo.CharacterTimeline_GetByProject", () => characters.GetTimelineAsync(projectId, bookId), projectId, bookId)
: ([], []);
var plotThreadDetailsById = plotThreads.ToDictionary(x => x.PlotThreadID);
foreach (var threadEvent in timeline.ThreadEvents)
{
if (plotThreadDetailsById.TryGetValue(threadEvent.PlotThreadID, out var thread))
{
threadEvent.ThreadStatusName = thread.ThreadStatusName;
threadEvent.Importance = thread.Importance;
}
}
var scenePovLabels = characterTimeline.Appearances
.Where(x => x.RoleInSceneTypeName?.Contains("POV", StringComparison.OrdinalIgnoreCase) == true)
.GroupBy(x => x.SceneID)
@ -2028,6 +2042,7 @@ public sealed class TimelineService(
PlotLanes = settings.ShowPlotLines ? BuildPlotLanes(timeline, orderedScenes) : [],
AssetLanes = settings.ShowStoryAssets ? BuildAssetLanes(assetTimeline.Assets, assetTimeline.Events, orderedScenes) : [],
CharacterLanes = settings.ShowCharacterAppearances ? BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes) : [],
WarningsByScene = warningsByScene,
MatchingSceneIDs = matchingSceneIds,
FocusSceneIDs = focusSceneIds,
ScenePovLabels = scenePovLabels,

View File

@ -963,6 +963,7 @@ public sealed class TimelineViewModel
public IReadOnlyList<PlotLineLaneViewModel> PlotLanes { get; set; } = [];
public IReadOnlyList<AssetLaneViewModel> AssetLanes { get; set; } = [];
public IReadOnlyList<CharacterLaneViewModel> CharacterLanes { get; set; } = [];
public IReadOnlyDictionary<int, IReadOnlyList<ContinuityWarning>> WarningsByScene { get; set; } = new Dictionary<int, IReadOnlyList<ContinuityWarning>>();
public IReadOnlySet<int> MatchingSceneIDs { get; set; } = new HashSet<int>();
public IReadOnlySet<int> FocusSceneIDs { get; set; } = new HashSet<int>();
public IReadOnlyDictionary<int, string> ScenePovLabels { get; set; } = new Dictionary<int, string>();

View File

@ -56,6 +56,101 @@
int BookSceneColumns(BookTimelineViewModel book) => Math.Max(book.Chapters.Any() ? book.Chapters.Sum(ChapterSceneColumns) : 1, 1);
var hasSelectedScene = Model.SelectedScene is not null;
int CharacterAppearanceCount(CharacterLaneViewModel lane) => lane.SceneSlots.Sum(slot => slot.Appearances.Count);
string PlotMarkerKind(string? plotEventTypeName) => (plotEventTypeName ?? "Progress").Trim().ToLowerInvariant() switch
{
"start" => "start",
"reveal" => "reveal",
"twist" => "twist",
"branch" => "branch",
"split" => "split",
"merge" => "merge",
"resolve" => "resolve",
"abandon" => "abandon",
_ => "progress"
};
string PlotMarkerIcon(string? plotEventTypeName) => PlotMarkerKind(plotEventTypeName) switch
{
"start" => ">",
"progress" => "+",
"reveal" => "o",
"twist" => "!",
"branch" => "B",
"split" => "Y",
"merge" => "M",
"resolve" => "OK",
"abandon" => "x",
_ => "+"
};
string ImportanceLabel(int importance) => importance switch
{
>= 8 => $"High ({importance})",
>= 4 => $"Medium ({importance})",
> 0 => $"Low ({importance})",
_ => "Not set"
};
string ThreadEventTooltip(ThreadEvent threadEvent, BookTimelineViewModel book, ChapterTimelineViewModel? chapter, Scene scene)
{
var lines = new List<string>
{
$"Thread: {threadEvent.ThreadTitle}",
$"Event: {threadEvent.PlotEventTypeName}",
$"Book: {book.Book.BookDisplayTitle}",
$"Chapter: {(chapter is null ? "No chapter" : $"{chapter.Chapter.ChapterNumber:g}: {chapter.Chapter.ChapterTitle}")}",
$"Scene: {scene.SceneNumber:g}: {scene.SceneTitle}",
$"Importance: {ImportanceLabel(threadEvent.Importance)}",
$"Status: {(string.IsNullOrWhiteSpace(threadEvent.ThreadStatusName) ? "Unknown" : threadEvent.ThreadStatusName)}"
};
if (!string.IsNullOrWhiteSpace(threadEvent.EventTitle))
{
lines.Add($"Marker: {threadEvent.EventTitle}");
}
if (!string.IsNullOrWhiteSpace(threadEvent.EventDescription))
{
lines.Add($"Summary: {threadEvent.EventDescription}");
}
return string.Join(Environment.NewLine, lines);
}
string SuggestedWarningAction(ContinuityWarning warning)
{
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.";
}
if (warningText.Contains("unresolved") || warningText.Contains("missing closure") || warningText.Contains("closure"))
{
return "Review the ending scenes and add a Resolve or Abandon event if the thread is finished.";
}
if (warningText.Contains("contradict") || warningText.Contains("conflict"))
{
return "Review the related scene details and update the conflicting story state.";
}
if (warningText.Contains("dependency"))
{
return "Review the linked scene dependency and adjust the ordering or dependency status.";
}
return "Open the warning dashboard or scene inspector and review the affected story detail.";
}
string WarningTooltip(Scene scene)
{
if (!Model.WarningsByScene.TryGetValue(scene.SceneID, out var sceneWarnings) || sceneWarnings.Count == 0)
{
return $"{scene.WarningCount} warning{(scene.WarningCount == 1 ? "" : "s")} on Scene {scene.SceneNumber:g}: {scene.SceneTitle}";
}
var lines = new List<string>
{
$"{sceneWarnings.Count} warning{(sceneWarnings.Count == 1 ? "" : "s")} on Scene {scene.SceneNumber:g}: {scene.SceneTitle}"
};
foreach (var warning in sceneWarnings.OrderByDescending(x => x.SeverityName).ThenBy(x => x.WarningTypeName))
{
lines.Add(string.Empty);
lines.Add($"Warning: {warning.WarningTypeName}");
lines.Add($"Reason: {(string.IsNullOrWhiteSpace(warning.Details) ? warning.Message : warning.Details)}");
lines.Add($"Suggested Action: {SuggestedWarningAction(warning)}");
}
return string.Join(Environment.NewLine, lines);
}
bool HasLeadSceneRole(CharacterLaneViewModel lane) => lane.SceneSlots
.SelectMany(slot => slot.Appearances)
.Any(appearance =>
@ -709,7 +804,10 @@ else
{
if (scene.WarningCount > 0)
{
<span class="warning-placeholder active" data-warning-marker>@scene.WarningCount warning@(scene.WarningCount == 1 ? "" : "s")</span>
<span class="warning-placeholder active" data-warning-marker title="@WarningTooltip(scene)">
<span aria-hidden="true">!</span>
<span>@scene.WarningCount warning@(scene.WarningCount == 1 ? "" : "s")</span>
</span>
}
else
{
@ -876,12 +974,13 @@ else
<div class="plot-lane-slot" data-plot-line-slot data-scene-id="@realPlotScene.SceneID" title="Scene @realPlotScene.SceneNumber: @realPlotScene.SceneTitle">
@foreach (var threadEvent in slot?.Events ?? Enumerable.Empty<ThreadEvent>())
{
var markerClass = threadEvent.EventTypeName is "Resolved" or "Payoff"
var markerKind = PlotMarkerKind(threadEvent.PlotEventTypeName);
var markerClass = markerKind == "resolve"
? "resolved"
: threadEvent.EventTypeName is "Contradicted"
: markerKind == "abandon"
? "contradicted"
: "";
<a class="thread-marker @markerClass"
<a class="thread-marker thread-marker-type-@markerKind @markerClass"
data-plot-event-node
data-plot-event-type="@threadEvent.PlotEventTypeName"
data-target-plot-line-id="@threadEvent.TargetPlotLineID"
@ -893,8 +992,9 @@ else
asp-route-SelectedSceneID="@realPlotScene.SceneID"
asp-route-FocusType="plotthread"
asp-route-FocusID="@threadEvent.PlotThreadID"
title="@threadEvent.ThreadTitle: @threadEvent.EventTitle">
@threadEvent.MarkerText
title="@ThreadEventTooltip(threadEvent, column.Book, column.Chapter, realPlotScene)">
<span class="thread-marker-icon" aria-hidden="true">@PlotMarkerIcon(threadEvent.PlotEventTypeName)</span>
<span class="thread-marker-text">@threadEvent.MarkerText</span>
</a>
}
</div>

View File

@ -960,6 +960,8 @@ body.timeline-inspector-resizing {
border-color: rgba(159, 69, 69, 0.36);
color: #8a3434;
background: rgba(255, 232, 232, 0.9);
gap: 5px;
font-weight: 800;
}
.dependency-placeholder {
@ -1104,15 +1106,13 @@ body.timeline-inspector-resizing {
opacity: 0.5;
}
.plot-event-svg-node.plotline-state-resolved circle,
.plot-event-svg-node.plotline-state-questionable circle {
fill: #8c9491;
.plot-event-svg-node.plotline-state-resolved,
.plot-event-svg-node.plotline-state-questionable {
opacity: 0.88;
}
.plot-event-svg-node.plotline-state-abandoned circle,
.plot-event-svg-node.plotline-state-abandoned path {
fill: #9b2f36;
stroke: #ffffff;
.plot-event-svg-node.plotline-state-abandoned {
opacity: 0.9;
}
.plot-event-svg-node.plotline-state-abandoned line {
@ -1156,24 +1156,36 @@ body.timeline-inspector-resizing {
}
.plot-event-svg-node circle,
.plot-event-svg-node ellipse,
.plot-event-svg-node path {
stroke: #ffffff;
stroke-width: 2;
}
.plot-event-svg-node line {
.plot-event-svg-node line,
.plot-event-svg-node path[fill="none"] {
stroke: #8b1e2d;
stroke-width: 4;
stroke-width: 3.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.plot-event-svg-node-reveal circle {
stroke-width: 3;
.plot-event-svg-node-reveal ellipse {
stroke-width: 2.4;
}
.plot-event-svg-node-resolve circle:last-child {
stroke: currentColor;
stroke-width: 2;
.plot-event-svg-label {
font-size: 9.5px;
font-weight: 900;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
paint-order: stroke;
stroke: rgba(30, 37, 43, 0.22);
stroke-width: 0.8px;
pointer-events: none;
}
.plot-event-svg-node-resolve path[fill="none"] {
stroke: #ffffff;
}
.lane-board-heading,
@ -1255,6 +1267,7 @@ body.timeline-inspector-resizing {
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%);
@ -2437,6 +2450,29 @@ body.timeline-inspector-resizing {
background: rgba(47, 111, 99, 0.08);
}
.thread-marker-icon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 14px;
font-size: 0.68rem;
font-weight: 900;
}
.thread-marker-type-reveal .thread-marker-icon {
border: 1px solid currentColor;
border-radius: 999px;
font-size: 0;
}
.thread-marker-type-reveal .thread-marker-icon::after {
content: "";
width: 4px;
height: 4px;
border-radius: 999px;
background: currentColor;
}
.timeline-navigation-card {
display: grid;
grid-template-areas:

File diff suppressed because one or more lines are too long

View File

@ -1694,47 +1694,86 @@
};
};
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 === "twist") {
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: colour
fill
}));
addNodeText(group, point, "!");
} else if (point.type === "abandon") {
group.append(makeSvg("circle", { cx: point.x, cy: point.y, r: 9, fill: "#fff5f5" }));
group.append(makeSvg("line", { x1: point.x - 7, y1: point.y - 7, x2: point.x + 7, y2: point.y + 7 }));
group.append(makeSvg("line", { x1: point.x + 7, y1: point.y - 7, x2: point.x - 7, y2: point.y + 7 }));
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("path", {
d: `M ${point.x} ${point.y - 10} L ${point.x + 3} ${point.y - 3} L ${point.x + 10} ${point.y - 3} L ${point.x + 4} ${point.y + 2} L ${point.x + 6} ${point.y + 10} L ${point.x} ${point.y + 5} L ${point.x - 6} ${point.y + 10} L ${point.x - 4} ${point.y + 2} L ${point.x - 10} ${point.y - 3} L ${point.x - 3} ${point.y - 3} Z`,
fill: colour
}));
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 : 7.5, fill: colour }));
group.append(makeSvg("circle", { cx: point.x, cy: point.y, r: isStructural ? 9.5 : 8.2, fill }));
if (point.type === "resolve") {
group.append(makeSvg("circle", { cx: point.x, cy: point.y, r: 12, fill: "none", stroke: colour }));
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: colour }));
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 drawHealthMarker = (point, kind, titleText, offsetIndex = 0) => {
const offsetX = 13 + offsetIndex * 15;
const group = makeSvg("g", { class: `plotline-health-marker plotline-health-marker-${kind}` });
const healthWarningTooltip = (warnings) => warnings.map((warning) => [
`Warning: ${warning.label}`,
`Reason: ${warning.reason}`,
`Suggested Action: ${warning.action}`
].join("\n")).join("\n\n");
const drawHealthMarker = (point, warnings) => {
const offsetX = 14;
const primaryKind = warnings[0]?.kind || "warning";
const group = makeSvg("g", { class: `plotline-health-marker plotline-health-marker-${primaryKind}` });
const title = makeSvg("title");
title.textContent = titleText;
title.textContent = healthWarningTooltip(warnings);
group.append(title);
if (kind === "questionable") {
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" });
text.textContent = "?";
text.textContent = warnings.length > 1 ? String(warnings.length) : "?";
group.append(text);
} else {
group.append(makeSvg("path", {
@ -1742,6 +1781,11 @@
}));
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 }));
if (warnings.length > 1) {
const text = makeSvg("text", { x: point.x + offsetX, y: point.y - 6, "text-anchor": "middle" });
text.textContent = String(warnings.length);
group.append(text);
}
}
svg.append(group);
@ -1833,22 +1877,56 @@
rowData.forEach((item) => {
const markerPoint = item.lineEndPoint || item.points[item.points.length - 1];
let offsetIndex = 0;
const healthWarnings = [];
if (item.health?.isNeglected && markerPoint) {
drawHealthMarker(markerPoint, "neglected", "No plot event for 10+ scenes. This thread may have been forgotten.", offsetIndex++);
healthWarnings.push({
point: markerPoint,
kind: "neglected",
label: "Dormant Thread",
reason: "No activity for 10+ scenes.",
action: "Add a Progress, Reveal or Resolve event."
});
}
if (item.health?.isDangling && markerPoint) {
drawHealthMarker(markerPoint, "dangling", "This plot line is still unresolved at the end of the current timeline.", offsetIndex++);
healthWarnings.push({
point: markerPoint,
kind: "dangling",
label: "Unresolved Thread",
reason: "Thread contains no Resolve or Abandon event by the end of the current timeline.",
action: "Review ending scenes and add Resolve or Abandon where appropriate."
});
}
if (item.health?.isQuestionable && item.health.resolvePoint) {
drawHealthMarker(item.health.resolvePoint, "questionable", "Resolved without merge, split, or branch. Verify this thread has sufficient payoff.");
healthWarnings.push({
point: item.health.resolvePoint,
kind: "questionable",
label: "Missing Closure",
reason: "Resolved without merge, split, or branch.",
action: "Verify this thread has sufficient payoff."
});
}
if (item.health?.isAbandoned && item.health.abandonPoint) {
drawHealthMarker(item.health.abandonPoint, "abandoned", "This plot line was abandoned. Check this is intentional.");
healthWarnings.push({
point: item.health.abandonPoint,
kind: "abandoned",
label: "Abandoned Thread",
reason: "This plot line was abandoned.",
action: "Check this is intentional."
});
}
const groupedWarnings = new Map();
healthWarnings.forEach((warning) => {
const key = `${Math.round(warning.point.x)}:${Math.round(warning.point.y)}`;
if (!groupedWarnings.has(key)) {
groupedWarnings.set(key, { point: warning.point, warnings: [] });
}
groupedWarnings.get(key).warnings.push(warning);
});
groupedWarnings.forEach(({ point, warnings }) => drawHealthMarker(point, warnings));
});
});
};

File diff suppressed because one or more lines are too long