304 lines
14 KiB
Plaintext
304 lines
14 KiB
Plaintext
@model AnalyticsViewModel
|
|
@{
|
|
ViewData["Title"] = "Story Health";
|
|
var openThreads = Model.ThreadHealth.Where(x => x.IsOpenStatus).ToList();
|
|
var dormantThreads = Model.ThreadHealth.Where(x => x.IsDormant).ToList();
|
|
var unresolvedAssets = Model.AssetHealth.Where(x => !x.IsResolved).ToList();
|
|
var metricScenes = Model.MetricGraphs
|
|
.SelectMany(graph => graph.Points)
|
|
.GroupBy(point => point.SceneID)
|
|
.Select(group => group.OrderBy(point => point.GlobalSceneIndex).First())
|
|
.OrderBy(point => point.GlobalSceneIndex)
|
|
.Select(point => new
|
|
{
|
|
sceneId = point.SceneID,
|
|
axisLabel = $"{point.ChapterNumber:g}.{point.SceneNumber:g}",
|
|
chapterLabel = $"Chapter {point.ChapterNumber:g}",
|
|
sceneNumber = point.SceneNumber.ToString("g"),
|
|
sceneTitle = point.SceneTitle,
|
|
bookTitle = point.BookTitle,
|
|
timelineUrl = Url.Action("Index", "Timeline", new { projectId = Model.Project.ProjectID, bookId = point.BookID, selectedSceneId = point.SceneID })
|
|
})
|
|
.ToList();
|
|
var metricShapePayload = new
|
|
{
|
|
scenes = metricScenes,
|
|
metrics = Model.MetricGraphs.Select(graph => new
|
|
{
|
|
metricName = graph.MetricName,
|
|
points = graph.Points.Select(point => new { sceneId = point.SceneID, value = point.Value })
|
|
})
|
|
};
|
|
}
|
|
|
|
<section class="context-header">
|
|
<div class="context-title">
|
|
<span class="eyebrow">Workspace context</span>
|
|
<strong>@Model.Project.ProjectName</strong>
|
|
</div>
|
|
<nav class="context-nav" aria-label="Workspace navigation">
|
|
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">Project</a>
|
|
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.BookID">Timeline</a>
|
|
<a asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.BookID">Warnings</a>
|
|
<a asp-controller="Characters" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
|
|
<a asp-controller="StoryAssets" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Assets</a>
|
|
<a asp-controller="Locations" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Locations</a>
|
|
<a asp-controller="PlotThreads" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Threads</a>
|
|
</nav>
|
|
</section>
|
|
|
|
<div class="page-heading compact">
|
|
<div>
|
|
<p class="eyebrow">Analytics / Story health</p>
|
|
<h1>@Model.Project.ProjectName</h1>
|
|
</div>
|
|
<div class="button-row">
|
|
<a class="btn btn-outline-primary" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.BookID">Timeline</a>
|
|
<a class="btn btn-outline-secondary" asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.BookID">Warnings</a>
|
|
</div>
|
|
</div>
|
|
|
|
<section class="warning-filter-panel">
|
|
<form method="get" class="row g-2 align-items-end">
|
|
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
|
<div class="col-md-3">
|
|
<label class="form-label">Book</label>
|
|
<select name="bookId" asp-items="Model.BookOptions" class="form-select form-select-sm">
|
|
<option value="">All books</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">Start chapter</label>
|
|
<input class="form-control form-control-sm" name="startChapterNumber" value="@Model.StartChapterNumber" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">End chapter</label>
|
|
<input class="form-control form-control-sm" name="endChapterNumber" value="@Model.EndChapterNumber" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">Start scene</label>
|
|
<input class="form-control form-control-sm" name="startSceneNumber" value="@Model.StartSceneNumber" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">End scene</label>
|
|
<input class="form-control form-control-sm" name="endSceneNumber" value="@Model.EndSceneNumber" />
|
|
</div>
|
|
<div class="col-md-1">
|
|
<button class="btn btn-outline-primary btn-sm" type="submit">View</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="health-kpi-grid">
|
|
<article class="plain-card"><span class="eyebrow">Open threads</span><strong>@openThreads.Count</strong></article>
|
|
<article class="plain-card"><span class="eyebrow">Dormant threads</span><strong>@dormantThreads.Count</strong></article>
|
|
<article class="plain-card"><span class="eyebrow">Unresolved assets</span><strong>@unresolvedAssets.Count</strong></article>
|
|
<article class="plain-card"><span class="eyebrow">Warning hotspots</span><strong>@Model.WarningHotspots.Count</strong></article>
|
|
</section>
|
|
|
|
<section class="analytics-section">
|
|
<div class="analytics-section-heading">
|
|
<h2>Scene metric shape</h2>
|
|
<span class="muted">@Model.MetricGraphs.Count metric@(Model.MetricGraphs.Count == 1 ? "" : "s")</span>
|
|
</div>
|
|
@if (!Model.MetricGraphs.Any() || !metricScenes.Any())
|
|
{
|
|
<p class="muted">Add scene metrics to begin seeing peaks and troughs across the story.</p>
|
|
}
|
|
else
|
|
{
|
|
<div class="metric-shape-chart" data-metric-shape-chart>
|
|
<p class="metric-shape-intro">Compare selected pacing and emotional metrics across scenes in narrative order. Click a point to open that scene on the timeline.</p>
|
|
<div class="metric-toggle-row" data-metric-toggles></div>
|
|
<div class="metric-chart-frame">
|
|
<canvas aria-label="Comparative scene metric line graph"></canvas>
|
|
<p class="muted d-none" data-chart-fallback>Metric chart could not load. The raw metric rows are still available below.</p>
|
|
</div>
|
|
<script type="application/json">@Html.Raw(System.Text.Json.JsonSerializer.Serialize(metricShapePayload))</script>
|
|
</div>
|
|
|
|
<details class="metric-raw-details">
|
|
<summary>Show raw metric rows</summary>
|
|
<div class="analytics-metric-stack mt-2">
|
|
@foreach (var graph in Model.MetricGraphs)
|
|
{
|
|
<div class="analytics-metric-row">
|
|
<div class="analytics-metric-label">@graph.MetricName</div>
|
|
<div class="analytics-sparkline" style="--point-count:@Math.Max(graph.Points.Count, 1)">
|
|
@foreach (var point in graph.Points)
|
|
{
|
|
<a class="analytics-bar"
|
|
style="--bar-height:@Math.Max(point.Percent, 4)%"
|
|
title="@graph.MetricName - Scene @point.SceneNumber: @point.SceneTitle (@point.Value)"
|
|
asp-controller="Timeline"
|
|
asp-action="Index"
|
|
asp-route-projectId="@Model.Project.ProjectID"
|
|
asp-route-bookId="@point.BookID"
|
|
asp-route-selectedSceneId="@point.SceneID">
|
|
<span>@point.Value</span>
|
|
</a>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</details>
|
|
}
|
|
</section>
|
|
|
|
<section class="analytics-grid-two">
|
|
<article class="plain-card">
|
|
<h2>Pacing review</h2>
|
|
@if (!Model.PacingReviewNotes.Any())
|
|
{
|
|
<p class="muted">No sustained pacing runs found in this scope.</p>
|
|
}
|
|
else
|
|
{
|
|
@foreach (var note in Model.PacingReviewNotes)
|
|
{
|
|
<div class="analytics-list-item">
|
|
<strong>@note.MetricName</strong>
|
|
<span>@note.SceneRange</span>
|
|
<span class="muted">@note.ValuePattern</span>
|
|
<p>@note.ReviewNote</p>
|
|
</div>
|
|
}
|
|
}
|
|
</article>
|
|
|
|
<article class="plain-card">
|
|
<h2>Revision summary</h2>
|
|
@foreach (var status in Model.RevisionSummary.Where(x => x.SceneCount > 0 || x.ChapterCount > 0))
|
|
{
|
|
<div class="analytics-count-row">
|
|
<span>@status.StatusName</span>
|
|
<strong>@status.SceneCount scenes</strong>
|
|
</div>
|
|
}
|
|
<h3>Needs attention</h3>
|
|
@foreach (var scene in Model.RevisionAttentionScenes.Take(8))
|
|
{
|
|
<a class="analytics-link-row" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@scene.BookID" asp-route-selectedSceneId="@scene.SceneID">
|
|
<span>@scene.RevisionStatusName</span>
|
|
<strong>Ch @scene.ChapterNumber / Scene @scene.SceneNumber: @scene.SceneTitle</strong>
|
|
</a>
|
|
}
|
|
</article>
|
|
</section>
|
|
|
|
<section class="analytics-grid-two">
|
|
<article class="plain-card">
|
|
<h2>Scene purpose balance</h2>
|
|
@foreach (var purpose in Model.PurposeSummary)
|
|
{
|
|
<div class="purpose-stat-row">
|
|
<span>@purpose.PurposeName</span>
|
|
<div class="purpose-stat-bar"><span style="width:@Math.Min(100, purpose.PercentOfScenes)%"></span></div>
|
|
<strong>@purpose.SceneCount</strong>
|
|
</div>
|
|
}
|
|
<h3>Missing purpose/outcome</h3>
|
|
@foreach (var gap in Model.SceneGaps.Take(8))
|
|
{
|
|
<a class="analytics-link-row" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@gap.BookID" asp-route-selectedSceneId="@gap.SceneID">
|
|
<span>@(gap.MissingPurpose ? "No purpose" : "") @(gap.MissingOutcome ? "No outcome" : "")</span>
|
|
<strong>Ch @gap.ChapterNumber / Scene @gap.SceneNumber: @gap.SceneTitle</strong>
|
|
</a>
|
|
}
|
|
</article>
|
|
|
|
<article class="plain-card">
|
|
<h2>Warning hotspots</h2>
|
|
@foreach (var count in Model.WarningSeverityCounts)
|
|
{
|
|
<div class="warning-count-row"><span class="severity-pill @count.SeverityName.ToLowerInvariant()">@count.SeverityName</span><strong>@count.WarningCount</strong></div>
|
|
}
|
|
@foreach (var hotspot in Model.WarningHotspots.Take(8))
|
|
{
|
|
<a class="analytics-link-row" asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.BookID" asp-route-sceneId="@hotspot.SceneID">
|
|
<span>@hotspot.WarningCount warning@(hotspot.WarningCount == 1 ? "" : "s")</span>
|
|
<strong>Ch @hotspot.ChapterNumber / Scene @hotspot.SceneNumber: @hotspot.SceneTitle</strong>
|
|
</a>
|
|
}
|
|
</article>
|
|
</section>
|
|
|
|
<section class="analytics-grid-two">
|
|
<article class="plain-card">
|
|
<h2>Plot thread health</h2>
|
|
@foreach (var thread in Model.ThreadHealth.Where(x => x.IsHighImportanceOpen || x.IsDormant || x.HasClueWithoutPayoff || x.HasQuestionWithoutAnswer).Take(12))
|
|
{
|
|
<div class="analytics-list-item">
|
|
<strong>@thread.ThreadTitle</strong>
|
|
<span>@thread.PlotLineName / @thread.ThreadStatusName / importance @thread.Importance</span>
|
|
<span class="muted">@thread.LastTouchedSceneLabel</span>
|
|
<div class="purpose-chip-row">
|
|
@if (thread.IsDormant) { <span class="purpose-chip">Dormant</span> }
|
|
@if (thread.HasClueWithoutPayoff) { <span class="purpose-chip">Clue without payoff</span> }
|
|
@if (thread.HasQuestionWithoutAnswer) { <span class="purpose-chip">Question open</span> }
|
|
</div>
|
|
</div>
|
|
}
|
|
</article>
|
|
|
|
<article class="plain-card">
|
|
<h2>Story asset health</h2>
|
|
@foreach (var asset in Model.AssetHealth.Where(x => !x.IsResolved || x.HasDependencies || x.Importance >= 8).Take(12))
|
|
{
|
|
<a class="analytics-link-row" asp-controller="StoryAssets" asp-action="Details" asp-route-id="@asset.StoryAssetID">
|
|
<span>@asset.KindName / importance @asset.Importance</span>
|
|
<strong>@asset.AssetName</strong>
|
|
<span>@(asset.CurrentStateName ?? "No state") / @(asset.CurrentLocationPath ?? "No location")</span>
|
|
</a>
|
|
}
|
|
</article>
|
|
</section>
|
|
|
|
<section class="analytics-grid-three">
|
|
<article class="plain-card">
|
|
<h2>POV distribution</h2>
|
|
@foreach (var pov in Model.PovSummary)
|
|
{
|
|
<div class="analytics-count-row"><span>@pov.POVName</span><strong>@pov.SceneCount</strong></div>
|
|
}
|
|
@foreach (var chapter in Model.MultiPovChapters.Take(6))
|
|
{
|
|
<p class="muted">Ch @chapter.ChapterNumber: @chapter.ChapterTitle has @chapter.POVCount POVs.</p>
|
|
}
|
|
</article>
|
|
|
|
<article class="plain-card">
|
|
<h2>Character appearances</h2>
|
|
@foreach (var character in Model.CharacterAppearances.Take(10))
|
|
{
|
|
<a class="analytics-link-row" asp-controller="Characters" asp-action="Details" asp-route-id="@character.CharacterID">
|
|
<span>@character.SceneCount scenes</span>
|
|
<strong>@character.CharacterName</strong>
|
|
</a>
|
|
}
|
|
@foreach (var busy in Model.BusyScenes.Take(4))
|
|
{
|
|
<p class="muted">Busy scene: Ch @busy.ChapterNumber / Scene @busy.SceneNumber has @busy.CharacterCount characters.</p>
|
|
}
|
|
</article>
|
|
|
|
<article class="plain-card">
|
|
<h2>Location usage</h2>
|
|
@foreach (var location in Model.LocationUsage.Take(10))
|
|
{
|
|
@if (location.LocationID.HasValue)
|
|
{
|
|
<a class="analytics-link-row" asp-controller="Locations" asp-action="Details" asp-route-id="@location.LocationID">
|
|
<span>@location.SceneCount scenes</span>
|
|
<strong>@location.LocationName</strong>
|
|
</a>
|
|
}
|
|
else
|
|
{
|
|
<div class="analytics-count-row"><span>@location.LocationName</span><strong>@location.SceneCount</strong></div>
|
|
}
|
|
}
|
|
</article>
|
|
</section>
|