Phase C1: Plot Thread Readability & Warning Clarity
This commit is contained in:
parent
783fb916f8
commit
5ece8d9451
@ -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; }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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:
|
||||
|
||||
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
@ -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));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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