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 int EventTypeID { get; set; }
|
||||||
public string EventTypeName { get; set; } = string.Empty;
|
public string EventTypeName { get; set; } = string.Empty;
|
||||||
public string MarkerText { 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 int PlotEventTypeID { get; set; } = 2;
|
||||||
public string PlotEventTypeName { get; set; } = "Progress";
|
public string PlotEventTypeName { get; set; } = "Progress";
|
||||||
public int? TargetPlotLineID { get; set; }
|
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 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 purposeLabelsByScene = timeline.PurposeLabels.GroupBy(x => x.SceneID).ToDictionary(x => x.Key, x => x.OrderBy(p => p.SortOrder).ToList());
|
||||||
var orderedScenes = timeline.Scenes.ToList();
|
var orderedScenes = timeline.Scenes.ToList();
|
||||||
var warningCounts = needsWarnings
|
var timelineWarnings = needsWarnings
|
||||||
? (await TimeTimelineLoadAsync("dbo.ContinuityWarning_SceneCounts", () => warnings.GetSceneCountsAsync(projectId, bookId), projectId, bookId)).ToDictionary(x => x.SceneID, x => x.WarningCount)
|
? await TimeTimelineLoadAsync("dbo.ContinuityWarning_List", () => warnings.ListAsync(projectId, bookId, null, filter.WarningTypeID, filter.WarningSeverityID, null, filter.IncludeDismissedWarnings, filter.IncludeIntentionalWarnings), projectId, bookId)
|
||||||
: new Dictionary<int, int>();
|
: [];
|
||||||
|
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);
|
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)
|
foreach (var scene in orderedScenes)
|
||||||
@ -1943,6 +1948,15 @@ public sealed class TimelineService(
|
|||||||
(IReadOnlyList<Character> Characters, IReadOnlyList<SceneCharacter> Appearances) characterTimeline = needsCharacterTimelineData
|
(IReadOnlyList<Character> Characters, IReadOnlyList<SceneCharacter> Appearances) characterTimeline = needsCharacterTimelineData
|
||||||
? await TimeTimelineLoadAsync("dbo.CharacterTimeline_GetByProject", () => characters.GetTimelineAsync(projectId, bookId), projectId, bookId)
|
? 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
|
var scenePovLabels = characterTimeline.Appearances
|
||||||
.Where(x => x.RoleInSceneTypeName?.Contains("POV", StringComparison.OrdinalIgnoreCase) == true)
|
.Where(x => x.RoleInSceneTypeName?.Contains("POV", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
.GroupBy(x => x.SceneID)
|
.GroupBy(x => x.SceneID)
|
||||||
@ -2028,6 +2042,7 @@ public sealed class TimelineService(
|
|||||||
PlotLanes = settings.ShowPlotLines ? BuildPlotLanes(timeline, orderedScenes) : [],
|
PlotLanes = settings.ShowPlotLines ? BuildPlotLanes(timeline, orderedScenes) : [],
|
||||||
AssetLanes = settings.ShowStoryAssets ? BuildAssetLanes(assetTimeline.Assets, assetTimeline.Events, orderedScenes) : [],
|
AssetLanes = settings.ShowStoryAssets ? BuildAssetLanes(assetTimeline.Assets, assetTimeline.Events, orderedScenes) : [],
|
||||||
CharacterLanes = settings.ShowCharacterAppearances ? BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes) : [],
|
CharacterLanes = settings.ShowCharacterAppearances ? BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes) : [],
|
||||||
|
WarningsByScene = warningsByScene,
|
||||||
MatchingSceneIDs = matchingSceneIds,
|
MatchingSceneIDs = matchingSceneIds,
|
||||||
FocusSceneIDs = focusSceneIds,
|
FocusSceneIDs = focusSceneIds,
|
||||||
ScenePovLabels = scenePovLabels,
|
ScenePovLabels = scenePovLabels,
|
||||||
|
|||||||
@ -963,6 +963,7 @@ public sealed class TimelineViewModel
|
|||||||
public IReadOnlyList<PlotLineLaneViewModel> PlotLanes { get; set; } = [];
|
public IReadOnlyList<PlotLineLaneViewModel> PlotLanes { get; set; } = [];
|
||||||
public IReadOnlyList<AssetLaneViewModel> AssetLanes { get; set; } = [];
|
public IReadOnlyList<AssetLaneViewModel> AssetLanes { get; set; } = [];
|
||||||
public IReadOnlyList<CharacterLaneViewModel> CharacterLanes { 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> MatchingSceneIDs { get; set; } = new HashSet<int>();
|
||||||
public IReadOnlySet<int> FocusSceneIDs { 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>();
|
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);
|
int BookSceneColumns(BookTimelineViewModel book) => Math.Max(book.Chapters.Any() ? book.Chapters.Sum(ChapterSceneColumns) : 1, 1);
|
||||||
var hasSelectedScene = Model.SelectedScene is not null;
|
var hasSelectedScene = Model.SelectedScene is not null;
|
||||||
int CharacterAppearanceCount(CharacterLaneViewModel lane) => lane.SceneSlots.Sum(slot => slot.Appearances.Count);
|
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
|
bool HasLeadSceneRole(CharacterLaneViewModel lane) => lane.SceneSlots
|
||||||
.SelectMany(slot => slot.Appearances)
|
.SelectMany(slot => slot.Appearances)
|
||||||
.Any(appearance =>
|
.Any(appearance =>
|
||||||
@ -709,7 +804,10 @@ else
|
|||||||
{
|
{
|
||||||
if (scene.WarningCount > 0)
|
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
|
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">
|
<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>())
|
@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"
|
? "resolved"
|
||||||
: threadEvent.EventTypeName is "Contradicted"
|
: markerKind == "abandon"
|
||||||
? "contradicted"
|
? "contradicted"
|
||||||
: "";
|
: "";
|
||||||
<a class="thread-marker @markerClass"
|
<a class="thread-marker thread-marker-type-@markerKind @markerClass"
|
||||||
data-plot-event-node
|
data-plot-event-node
|
||||||
data-plot-event-type="@threadEvent.PlotEventTypeName"
|
data-plot-event-type="@threadEvent.PlotEventTypeName"
|
||||||
data-target-plot-line-id="@threadEvent.TargetPlotLineID"
|
data-target-plot-line-id="@threadEvent.TargetPlotLineID"
|
||||||
@ -893,8 +992,9 @@ else
|
|||||||
asp-route-SelectedSceneID="@realPlotScene.SceneID"
|
asp-route-SelectedSceneID="@realPlotScene.SceneID"
|
||||||
asp-route-FocusType="plotthread"
|
asp-route-FocusType="plotthread"
|
||||||
asp-route-FocusID="@threadEvent.PlotThreadID"
|
asp-route-FocusID="@threadEvent.PlotThreadID"
|
||||||
title="@threadEvent.ThreadTitle: @threadEvent.EventTitle">
|
title="@ThreadEventTooltip(threadEvent, column.Book, column.Chapter, realPlotScene)">
|
||||||
@threadEvent.MarkerText
|
<span class="thread-marker-icon" aria-hidden="true">@PlotMarkerIcon(threadEvent.PlotEventTypeName)</span>
|
||||||
|
<span class="thread-marker-text">@threadEvent.MarkerText</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -960,6 +960,8 @@ body.timeline-inspector-resizing {
|
|||||||
border-color: rgba(159, 69, 69, 0.36);
|
border-color: rgba(159, 69, 69, 0.36);
|
||||||
color: #8a3434;
|
color: #8a3434;
|
||||||
background: rgba(255, 232, 232, 0.9);
|
background: rgba(255, 232, 232, 0.9);
|
||||||
|
gap: 5px;
|
||||||
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dependency-placeholder {
|
.dependency-placeholder {
|
||||||
@ -1104,15 +1106,13 @@ body.timeline-inspector-resizing {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plot-event-svg-node.plotline-state-resolved circle,
|
.plot-event-svg-node.plotline-state-resolved,
|
||||||
.plot-event-svg-node.plotline-state-questionable circle {
|
.plot-event-svg-node.plotline-state-questionable {
|
||||||
fill: #8c9491;
|
opacity: 0.88;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plot-event-svg-node.plotline-state-abandoned circle,
|
.plot-event-svg-node.plotline-state-abandoned {
|
||||||
.plot-event-svg-node.plotline-state-abandoned path {
|
opacity: 0.9;
|
||||||
fill: #9b2f36;
|
|
||||||
stroke: #ffffff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plot-event-svg-node.plotline-state-abandoned line {
|
.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 circle,
|
||||||
|
.plot-event-svg-node ellipse,
|
||||||
.plot-event-svg-node path {
|
.plot-event-svg-node path {
|
||||||
stroke: #ffffff;
|
stroke: #ffffff;
|
||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plot-event-svg-node line {
|
.plot-event-svg-node line,
|
||||||
|
.plot-event-svg-node path[fill="none"] {
|
||||||
stroke: #8b1e2d;
|
stroke: #8b1e2d;
|
||||||
stroke-width: 4;
|
stroke-width: 3.4;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plot-event-svg-node-reveal circle {
|
.plot-event-svg-node-reveal ellipse {
|
||||||
stroke-width: 3;
|
stroke-width: 2.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plot-event-svg-node-resolve circle:last-child {
|
.plot-event-svg-label {
|
||||||
stroke: currentColor;
|
font-size: 9.5px;
|
||||||
stroke-width: 2;
|
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,
|
.lane-board-heading,
|
||||||
@ -1255,6 +1267,7 @@ body.timeline-inspector-resizing {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
min-width: 26px;
|
min-width: 26px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border: 1px solid color-mix(in srgb, var(--plot-colour), #000000 18%);
|
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);
|
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 {
|
.timeline-navigation-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
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 drawNode = (point, colour, state) => {
|
||||||
const group = makeSvg("g", { class: `plot-event-svg-node plot-event-svg-node-${point.type} plotline-state-${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";
|
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", {
|
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`,
|
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") {
|
} else if (point.type === "abandon") {
|
||||||
group.append(makeSvg("circle", { cx: point.x, cy: point.y, r: 9, fill: "#fff5f5" }));
|
group.append(makeSvg("circle", { cx: point.x, cy: point.y, r: 9.5, fill }));
|
||||||
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 - 5.5, y1: point.y - 5.5, x2: point.x + 5.5, y2: point.y + 5.5 }));
|
||||||
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 + 5.5, y1: point.y - 5.5, x2: point.x - 5.5, y2: point.y + 5.5 }));
|
||||||
} else if (point.type === "reveal") {
|
} else if (point.type === "reveal") {
|
||||||
group.append(makeSvg("path", {
|
group.append(makeSvg("ellipse", { cx: point.x, cy: point.y, rx: 11, ry: 7.5, fill }));
|
||||||
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`,
|
group.append(makeSvg("circle", { cx: point.x, cy: point.y, r: 3.2, fill: "#ffffff" }));
|
||||||
fill: colour
|
|
||||||
}));
|
|
||||||
} else {
|
} 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") {
|
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) {
|
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);
|
svg.append(group);
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawHealthMarker = (point, kind, titleText, offsetIndex = 0) => {
|
const healthWarningTooltip = (warnings) => warnings.map((warning) => [
|
||||||
const offsetX = 13 + offsetIndex * 15;
|
`Warning: ${warning.label}`,
|
||||||
const group = makeSvg("g", { class: `plotline-health-marker plotline-health-marker-${kind}` });
|
`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");
|
const title = makeSvg("title");
|
||||||
title.textContent = titleText;
|
title.textContent = healthWarningTooltip(warnings);
|
||||||
group.append(title);
|
group.append(title);
|
||||||
|
|
||||||
if (kind === "questionable") {
|
if (primaryKind === "questionable") {
|
||||||
group.append(makeSvg("circle", { cx: point.x + offsetX, cy: point.y - 10, r: 7 }));
|
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" });
|
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);
|
group.append(text);
|
||||||
} else {
|
} else {
|
||||||
group.append(makeSvg("path", {
|
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("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("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);
|
svg.append(group);
|
||||||
@ -1833,22 +1877,56 @@
|
|||||||
|
|
||||||
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];
|
||||||
let offsetIndex = 0;
|
const healthWarnings = [];
|
||||||
if (item.health?.isNeglected && markerPoint) {
|
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) {
|
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) {
|
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) {
|
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