487 lines
23 KiB
Plaintext
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>
|
|
}
|