2026-06-23 19:47:30 +01:00

487 lines
23 KiB
Plaintext

@using System.Text.Json
@model RelationshipMapViewModel
@{
ViewData["Title"] = $"Relationship Map - {Model.Project.ProjectName}";
ViewData["ProjectSection"] = "Relationships";
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>
<partial name="_ProjectSectionNav" model="Model.Project" />
<div class="page-heading">
<div>
<p class="eyebrow">Relationships</p>
<h1>Relationship Map <help-icon key="relationshipMap.overview" /></h1>
<p class="lead-text">A read-only network of character relationships as of @Model.StoryPointLabel.</p>
</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 plain-card" aria-label="Relationship categories">
<div>
<p class="eyebrow">Relationship key</p>
<h2>Category colours</h2>
</div>
<div class="relationship-map-legend-grid">
@foreach (var category in Model.Categories)
{
<span class="relationship-map-legend-item @category.CssClass">
<span class="relationship-map-legend-line" aria-hidden="true"></span>
<span>@category.CategoryName</span>
</span>
}
</div>
</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">Add initial relationships or relationship events, choose a later story point, or remove the character focus to build the map.</p>
</div>
}
else
{
<div class="relationship-map-toolbar" aria-label="Map controls">
<button class="btn btn-outline-secondary btn-sm" type="button" data-map-fit>Fit</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-map-reset>Reset view</button>
</div>
<div id="relationshipMapGraph" class="relationship-map-graph" role="img" aria-label="Relationship network"></div>
<p id="relationshipMapError" class="alert alert-warning d-none mt-3">The relationship map renderer could not load. The relationship list below is still available.</p>
}
</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">
<div class="relationship-map-detail-heading">
<span class="visual-avatar visual-avatar--lg d-none" data-map-detail-avatar aria-hidden="true"></span>
<h2 data-map-detail-title></h2>
</div>
<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 src="https://cdn.jsdelivr.net/npm/cytoscape/dist/cytoscape.min.js"></script>
<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 relationshipById = new Map(relationships.map(item => [item.characterRelationshipID, item]));
const nodeById = new Map(nodes.map(item => [item.characterID, item]));
const graph = document.getElementById("relationshipMapGraph");
const error = document.getElementById("relationshipMapError");
if (!graph || !nodes.length) return;
if (!window.cytoscape) {
error?.classList.remove("d-none");
return;
}
const focusCharacterId = @Html.Raw(JsonSerializer.Serialize(Model.FocusCharacterID));
const fullNetwork = @Html.Raw(JsonSerializer.Serialize(Model.ShowFullNetwork));
const connectedToFocus = new Set();
if (focusCharacterId && fullNetwork) {
links.forEach(link => {
if (link.sourceCharacterID === focusCharacterId) connectedToFocus.add(link.targetCharacterID);
if (link.targetCharacterID === focusCharacterId) connectedToFocus.add(link.sourceCharacterID);
});
}
const elements = [
...nodes.map(node => ({
group: "nodes",
data: {
id: `c-${node.characterID}`,
characterID: node.characterID,
label: node.characterName,
importance: node.characterImportance || 0,
width: node.isFocus ? 172 : node.characterImportance >= 8 ? 158 : node.characterImportance >= 4 ? 144 : 128,
height: node.isFocus ? 58 : node.characterImportance >= 8 ? 52 : 46,
isFocus: node.isFocus,
isNeighbour: connectedToFocus.has(node.characterID)
},
classes: [
node.isFocus ? "is-focus" : "",
node.characterImportance >= 8 ? "is-major-character" : "",
node.characterImportance >= 4 && node.characterImportance < 8 ? "is-supporting-character" : "",
focusCharacterId && fullNetwork && !node.isFocus && !connectedToFocus.has(node.characterID) ? "is-dimmed" : ""
].filter(Boolean).join(" ")
})),
...links.map(link => ({
group: "edges",
data: {
id: `r-${link.characterRelationshipID}`,
relationshipID: link.characterRelationshipID,
source: `c-${link.sourceCharacterID}`,
target: `c-${link.targetCharacterID}`,
label: link.relationshipLabel,
category: link.categoryName,
intensity: link.intensity || 4,
tooltip: buildRelationshipTooltip(relationshipById.get(link.characterRelationshipID))
},
classes: [
link.cssClass,
focusCharacterId && fullNetwork && link.sourceCharacterID !== focusCharacterId && link.targetCharacterID !== focusCharacterId ? "is-dimmed" : ""
].filter(Boolean).join(" ")
}))
];
const cy = cytoscape({
container: graph,
elements,
minZoom: 0.25,
maxZoom: 2.5,
wheelSensitivity: 0.18,
style: relationshipMapStyle(),
layout: {
name: nodes.length <= 8 ? "circle" : "cose",
animate: false,
fit: true,
padding: 48,
nodeRepulsion: 9000,
idealEdgeLength: 170,
edgeElasticity: 80,
numIter: 1200,
randomize: false
}
});
cy.ready(() => {
cy.fit(undefined, 48);
const first = relationships[0];
if (first) selectRelationship(first.characterRelationshipID);
});
cy.on("tap", "edge", event => {
selectRelationship(event.target.data("relationshipID"));
});
cy.on("tap", "node", event => {
const characterId = event.target.data("characterID");
const characterName = event.target.data("label");
const character = nodeById.get(characterId);
document.getElementById("relationshipMapEmptyDetail")?.classList.add("d-none");
document.getElementById("relationshipMapDetail")?.classList.remove("d-none");
document.querySelector("[data-map-detail-title]").textContent = characterName;
renderDetailAvatar(character);
document.querySelector("[data-map-detail-category]").textContent = "Character";
document.querySelector("[data-map-detail-reciprocal]").textContent = characterId === focusCharacterId ? "Focused" : "Node";
document.querySelector("[data-map-detail-type]").textContent = "Character";
document.querySelector("[data-map-detail-initial]").textContent = "Select a relationship line to see relationship state.";
document.querySelector("[data-map-detail-current]").textContent = "Use Character focus to centre this character.";
const history = document.querySelector("[data-map-detail-history]");
history.innerHTML = "";
const li = document.createElement("li");
li.innerHTML = `<a href="/Characters/Details/${characterId}">Open character details</a> or <a href="/RelationshipMap?projectId=@Model.Project.ProjectID&focusCharacterId=${characterId}">focus this character</a>.`;
history.append(li);
});
cy.on("mouseover", "node, edge", event => {
const label = event.target.isNode() ? event.target.data("label") : event.target.data("tooltip");
graph.setAttribute("title", label || "");
});
document.querySelector("[data-map-fit]")?.addEventListener("click", () => {
cy.resize();
cy.fit(undefined, 48);
});
document.querySelector("[data-map-reset]")?.addEventListener("click", () => {
cy.layout({
name: nodes.length <= 8 ? "circle" : "cose",
animate: false,
fit: true,
padding: 48,
randomize: false
}).run();
});
window.addEventListener("resize", () => {
window.requestAnimationFrame(() => {
cy.resize();
cy.fit(undefined, 48);
});
});
document.querySelectorAll("[data-map-select]").forEach(button => {
button.addEventListener("click", () => selectRelationship(Number(button.dataset.mapSelect)));
});
function selectRelationship(id) {
const relationship = relationshipById.get(id);
if (!relationship) return;
cy.elements().removeClass("is-selected");
cy.$id(`r-${id}`).addClass("is-selected");
document.querySelectorAll(".relationship-map-list-item").forEach(item => item.classList.remove("is-selected"));
document.querySelectorAll(`[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}`;
renderDetailAvatar(null);
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;
}
function renderDetailAvatar(character) {
const avatar = document.querySelector("[data-map-detail-avatar]");
if (!avatar) return;
avatar.innerHTML = "";
if (!character) {
avatar.classList.add("d-none");
return;
}
const imagePath = character.avatarThumbnailPath || character.avatarImagePath || character.thumbnailPath || character.imagePath;
if (imagePath) {
const image = document.createElement("img");
image.src = imagePath;
image.alt = "";
image.loading = "lazy";
avatar.append(image);
} else {
const fallback = document.createElement("span");
fallback.textContent = initials(character.characterName);
avatar.append(fallback);
}
avatar.title = character.characterName;
avatar.classList.remove("d-none");
}
function initials(value) {
const parts = (value || "")
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2);
const text = parts.map(part => part[0]).join("").toUpperCase();
return text || "?";
}
function buildRelationshipTooltip(relationship) {
if (!relationship) return "";
return `${relationship.characterAName} - ${relationship.characterBName}: ${relationship.relationshipTypeName}; current ${formatState(relationship.currentStateName, relationship.currentIntensity)}`;
}
function relationshipMapStyle() {
const isDark = document.documentElement.dataset.theme === "dark";
const nodeFill = isDark ? "#222126" : "#fffdf8";
const nodeText = isDark ? "#f7efe4" : "#1f2937";
const focusFill = isDark ? "#3b2a16" : "#fff3d8";
const majorFill = isDark ? "#332417" : "#fff0d5";
const supportingFill = isDark ? "#29252c" : "#fbf6ef";
const labelFill = isDark ? "#f7efe4" : "#2f3742";
const labelBackground = isDark ? "#17171c" : "#fffdf8";
return [
{
selector: "node",
style: {
"label": "data(label)",
"shape": "round-rectangle",
"width": "data(width)",
"height": "data(height)",
"background-color": nodeFill,
"border-color": "#6b7280",
"border-width": 2,
"color": nodeText,
"font-size": 12,
"font-weight": 650,
"text-wrap": "wrap",
"text-max-width": 118,
"text-valign": "center",
"text-halign": "center",
"text-margin-y": 0,
"padding": "10px",
"shadow-blur": 10,
"shadow-color": isDark ? "rgba(0, 0, 0, 0.45)" : "rgba(31, 41, 55, 0.18)",
"shadow-opacity": 0.32,
"shadow-offset-y": 3,
"overlay-opacity": 0
}
},
{
selector: "node.is-supporting-character",
style: {
"background-color": supportingFill,
"border-color": isDark ? "#b9a891" : "#b99970",
"border-width": 2.5,
"font-weight": 700
}
},
{
selector: "node.is-major-character",
style: {
"background-color": majorFill,
"border-color": "#c47c2c",
"border-width": 3,
"font-size": 13,
"font-weight": 800,
"shadow-opacity": 0.42
}
},
{
selector: "node.is-focus",
style: {
"width": "data(width)",
"height": "data(height)",
"background-color": focusFill,
"border-color": "#c47c2c",
"border-width": 4,
"font-size": 13,
"font-weight": 850,
"shadow-opacity": 0.5
}
},
{
selector: "edge",
style: {
"label": "data(label)",
"curve-style": "bezier",
"width": "mapData(intensity, 1, 10, 2, 8)",
"line-color": "#7a8796",
"target-arrow-shape": "none",
"font-size": 11,
"font-weight": 700,
"color": labelFill,
"text-background-color": labelBackground,
"text-background-opacity": 0.88,
"text-background-padding": 3,
"text-rotation": "none",
"overlay-opacity": 0
}
},
{ selector: "edge.relationship-category-family", style: { "line-color": "#b86522", "color": "#7c3f12" } },
{ selector: "edge.relationship-category-friendship", style: { "line-color": "#2f9e5f", "color": "#14532d" } },
{ selector: "edge.relationship-category-romantic", style: { "line-color": "#d9467f", "color": "#831843" } },
{ selector: "edge.relationship-category-hostile", style: { "line-color": "#dc3f3f", "color": "#7f1d1d" } },
{ selector: "edge.relationship-category-professional", style: { "line-color": "#4f7fd8", "color": "#1e3a8a" } },
{ selector: "edge.relationship-category-other", style: { "line-color": "#7a8796", "color": "#374151" } },
{
selector: ".is-dimmed",
style: {
"opacity": 0.25
}
},
{
selector: "edge.is-selected",
style: {
"width": 9,
"z-index": 20,
"text-background-opacity": 1
}
},
{
selector: "node:selected",
style: {
"border-color": "#c47c2c",
"border-width": 4
}
}
];
}
})();
</script>
}