Nick Beckley 1410666a39 Phase 2D
2026-06-03 22:22:36 +01:00

271 lines
13 KiB
Plaintext

@using System.Text.Json
@model RelationshipMapViewModel
@{
ViewData["Title"] = $"Relationship Map - {Model.Project.ProjectName}";
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<span>Relationship Map</span>
</nav>
<section class="context-header">
<div class="context-title">
<span class="eyebrow">Project workspace</span>
<strong>@Model.Project.ProjectName</strong>
</div>
<nav class="context-nav" aria-label="Project navigation">
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Timeline</a>
<a asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story Bible</a>
<a class="active" asp-controller="RelationshipMap" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Relationship Map</a>
<a asp-controller="Characters" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
</nav>
</section>
<div class="page-heading">
<div>
<p class="eyebrow">Relationships</p>
<h1>Relationship Map</h1>
<p class="lead-text">A read-only network of character relationships as of @Model.StoryPointLabel.</p>
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story Bible</a>
<a class="btn btn-outline-secondary" asp-controller="Characters" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
</div>
</div>
<form class="relationship-map-controls plain-card" method="get" asp-action="Index">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<div>
<label class="form-label" for="bookId">Book</label>
<select class="form-select form-select-sm" id="bookId" name="bookId" asp-items="Model.BookOptions">
<option value="">Project Start</option>
</select>
</div>
<div>
<label class="form-label" for="chapterId">Chapter</label>
<select class="form-select form-select-sm" id="chapterId" name="chapterId" asp-items="Model.ChapterOptions">
<option value="">Any chapter</option>
</select>
</div>
<div>
<label class="form-label" for="sceneId">Scene</label>
<select class="form-select form-select-sm" id="sceneId" name="sceneId" asp-items="Model.SceneOptions">
<option value="">Any scene</option>
</select>
</div>
<div>
<label class="form-label" for="focusCharacterId">Character focus</label>
<select class="form-select form-select-sm" id="focusCharacterId" name="focusCharacterId" asp-items="Model.CharacterOptions">
<option value="">Full cast</option>
</select>
</div>
<label class="form-check relationship-map-check">
<input class="form-check-input" type="checkbox" name="showFullNetwork" value="true" checked="@Model.ShowFullNetwork" />
<span class="form-check-label">Show full network</span>
</label>
<button class="btn btn-primary btn-sm" type="submit">Update Map</button>
<a class="btn btn-outline-secondary btn-sm" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Project Start</a>
</form>
<section class="relationship-map-legend" aria-label="Relationship categories">
@foreach (var category in Model.Categories)
{
<span class="relationship-map-legend-item @category.CssClass">@category.CategoryName</span>
}
</section>
<section class="relationship-map-workspace">
<div class="relationship-map-canvas plain-card">
@if (!Model.Nodes.Any() || !Model.Links.Any())
{
<div class="empty-state">
<h2>No visible relationships at this point</h2>
<p class="muted">Try choosing a later book, chapter, or scene, or remove the character focus.</p>
</div>
}
else
{
<svg id="relationshipMapSvg" class="relationship-map-svg" role="img" aria-label="Relationship network"></svg>
}
</div>
<aside class="relationship-map-details plain-card">
<p class="eyebrow">Relationship detail</p>
<div id="relationshipMapEmptyDetail">
<h2>Select a relationship</h2>
<p class="muted">Click a connecting line or choose a relationship below to inspect its current state and history.</p>
</div>
<div id="relationshipMapDetail" class="d-none">
<h2 data-map-detail-title></h2>
<div class="relationship-map-detail-badges">
<span class="status-pill" data-map-detail-category></span>
<span class="status-pill" data-map-detail-reciprocal></span>
</div>
<dl class="relationship-map-detail-list">
<dt>Relationship type</dt>
<dd data-map-detail-type></dd>
<dt>Initial state</dt>
<dd data-map-detail-initial></dd>
<dt>Current state</dt>
<dd data-map-detail-current></dd>
</dl>
<h3>History</h3>
<ol class="relationship-map-history" data-map-detail-history></ol>
</div>
</aside>
</section>
<section class="list-section">
<h2>Visible relationships</h2>
@if (!Model.Relationships.Any())
{
<p class="muted">No relationships are visible for this map state.</p>
}
else
{
<div class="relationship-map-list">
@foreach (var relationship in Model.Relationships)
{
<button class="relationship-map-list-item" type="button" data-map-select="@relationship.CharacterRelationshipID">
<strong>@relationship.CharacterAName - @relationship.CharacterBName</strong>
<span>@relationship.CurrentStateName</span>
<span class="status-pill @relationship.CssClass">@relationship.RelationshipCategoryName</span>
</button>
}
</div>
}
</section>
@section Scripts {
<script>
(() => {
const nodes = @Html.Raw(JsonSerializer.Serialize(Model.Nodes, jsonOptions));
const links = @Html.Raw(JsonSerializer.Serialize(Model.Links, jsonOptions));
const relationships = @Html.Raw(JsonSerializer.Serialize(Model.Relationships, jsonOptions));
const svg = document.getElementById("relationshipMapSvg");
if (!svg || !nodes.length) return;
const width = 960;
const height = 620;
const centreX = width / 2;
const centreY = height / 2;
const radius = Math.max(180, Math.min(width, height) / 2 - 90);
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
const positions = new Map();
nodes.forEach((node, index) => {
const angle = nodes.length === 1 ? -Math.PI / 2 : (-Math.PI / 2) + (index * 2 * Math.PI / nodes.length);
const nodeRadius = node.isFocus ? radius * 0.15 : radius;
positions.set(node.characterID, {
x: centreX + Math.cos(angle) * nodeRadius,
y: centreY + Math.sin(angle) * nodeRadius
});
});
const relationshipById = new Map(relationships.map(item => [item.characterRelationshipID, item]));
const makeSvg = name => document.createElementNS("http://www.w3.org/2000/svg", name);
const linkLayer = makeSvg("g");
const nodeLayer = makeSvg("g");
svg.append(linkLayer, nodeLayer);
links.forEach(link => {
const from = positions.get(link.sourceCharacterID);
const to = positions.get(link.targetCharacterID);
if (!from || !to) return;
const group = makeSvg("g");
group.classList.add("relationship-map-link-group", link.cssClass);
group.dataset.relationshipId = link.characterRelationshipID;
group.setAttribute("tabindex", "0");
group.setAttribute("role", "button");
group.setAttribute("aria-label", `${link.relationshipLabel} relationship`);
const line = makeSvg("line");
line.setAttribute("x1", from.x);
line.setAttribute("y1", from.y);
line.setAttribute("x2", to.x);
line.setAttribute("y2", to.y);
line.setAttribute("stroke-width", Math.max(3, Math.min(9, link.intensity || 4)));
const label = makeSvg("text");
label.setAttribute("x", (from.x + to.x) / 2);
label.setAttribute("y", (from.y + to.y) / 2 - 8);
label.setAttribute("text-anchor", "middle");
label.textContent = link.relationshipLabel;
group.append(line, label);
group.addEventListener("click", () => selectRelationship(link.characterRelationshipID));
group.addEventListener("keydown", event => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectRelationship(link.characterRelationshipID);
}
});
linkLayer.append(group);
});
nodes.forEach(node => {
const position = positions.get(node.characterID);
const group = makeSvg("g");
group.classList.add("relationship-map-node");
if (node.isFocus) group.classList.add("is-focus");
const circle = makeSvg("circle");
circle.setAttribute("cx", position.x);
circle.setAttribute("cy", position.y);
circle.setAttribute("r", node.isFocus ? 34 : 27);
const label = makeSvg("text");
label.setAttribute("x", position.x);
label.setAttribute("y", position.y + 50);
label.setAttribute("text-anchor", "middle");
label.textContent = node.characterName;
group.append(circle, label);
nodeLayer.append(group);
});
document.querySelectorAll("[data-map-select]").forEach(button => {
button.addEventListener("click", () => selectRelationship(Number(button.dataset.mapSelect)));
});
if (relationships.length) {
selectRelationship(relationships[0].characterRelationshipID);
}
function selectRelationship(id) {
const relationship = relationshipById.get(id);
if (!relationship) return;
document.querySelectorAll(".relationship-map-link-group, .relationship-map-list-item").forEach(item => item.classList.remove("is-selected"));
document.querySelectorAll(`[data-relationship-id="${id}"], [data-map-select="${id}"]`).forEach(item => item.classList.add("is-selected"));
document.getElementById("relationshipMapEmptyDetail")?.classList.add("d-none");
document.getElementById("relationshipMapDetail")?.classList.remove("d-none");
document.querySelector("[data-map-detail-title]").textContent = `${relationship.characterAName} - ${relationship.characterBName}`;
document.querySelector("[data-map-detail-category]").textContent = relationship.relationshipCategoryName;
document.querySelector("[data-map-detail-reciprocal]").textContent = relationship.isReciprocal ? "Reciprocal" : "Directional";
document.querySelector("[data-map-detail-type]").textContent = relationship.relationshipTypeName;
document.querySelector("[data-map-detail-initial]").textContent = formatState(relationship.initialStateName, relationship.initialIntensity);
document.querySelector("[data-map-detail-current]").textContent = formatState(relationship.currentStateName, relationship.currentIntensity);
const history = document.querySelector("[data-map-detail-history]");
history.innerHTML = "";
relationship.history.forEach(item => {
const li = document.createElement("li");
const notes = item.notes ? ` - ${item.notes}` : "";
li.textContent = `${item.label}: ${formatState(item.stateName, item.intensity)}${notes}`;
history.append(li);
});
}
function formatState(name, intensity) {
return intensity ? `${name} / Intensity ${intensity}` : name;
}
})();
</script>
}