271 lines
13 KiB
Plaintext
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>
|
|
}
|