Compare commits

...

3 Commits

6 changed files with 768 additions and 128 deletions

View File

@ -33,6 +33,7 @@ var tests = new (string Name, Action Test)[]
("Illustration storage codes are normalised safely", IllustrationStorageCodesAreNormalisedSafely), ("Illustration storage codes are normalised safely", IllustrationStorageCodesAreNormalisedSafely),
("Illustration starter batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts), ("Illustration starter batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts),
("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems), ("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems),
("Story Intelligence prototype uses starter illustration codes", StoryIntelligencePrototypeUsesStarterIllustrationCodes),
("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts), ("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts),
("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes), ("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes),
("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel), ("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel),
@ -336,9 +337,14 @@ static void IllustrationPromptBuilderSeparatesSpecAndPrompt()
var result = builder.Build(spec); var result = builder.Build(spec);
Assert(result.TemplateVersion == IllustrationPromptBuilder.CurrentTemplateVersion, "Template version was not recorded."); Assert(result.TemplateVersion == IllustrationPromptBuilder.CurrentTemplateVersion, "Template version was not recorded.");
Assert(result.TemplateVersion == "21D.2", "Illustration prompt template version should reflect the vivid art-direction reset.");
Assert(result.Prompt.Contains("no visible text", StringComparison.OrdinalIgnoreCase), "Prompt did not include visual safety guidance."); Assert(result.Prompt.Contains("no visible text", StringComparison.OrdinalIgnoreCase), "Prompt did not include visual safety guidance.");
Assert(result.Prompt.Contains("Category composition rules", StringComparison.Ordinal), "Prompt did not include category composition rules."); Assert(result.Prompt.Contains("Category composition rules", StringComparison.Ordinal), "Prompt did not include category composition rules.");
Assert(result.Prompt.Contains("Structured details", StringComparison.Ordinal), "Prompt did not include structured details."); Assert(result.Prompt.Contains("Structured details", StringComparison.Ordinal), "Prompt did not include structured details.");
Assert(result.Prompt.Contains("modern cinematic digital illustration", StringComparison.OrdinalIgnoreCase), "Prompt did not include the new cinematic digital art direction.");
Assert(result.Prompt.Contains("premium narrative game concept art", StringComparison.OrdinalIgnoreCase), "Prompt did not include premium game concept-art guidance.");
Assert(result.Prompt.Contains("bright balanced lighting", StringComparison.OrdinalIgnoreCase), "Prompt did not include brighter lighting guidance.");
Assert(result.Prompt.Contains("avoid muddy olive and brown grading", StringComparison.OrdinalIgnoreCase), "Prompt did not explicitly avoid the previous muddy grading failure mode.");
Assert(result.Prompt.Contains(spec.Code, StringComparison.Ordinal), "Prompt did not include stable code."); Assert(result.Prompt.Contains(spec.Code, StringComparison.Ordinal), "Prompt did not include stable code.");
Assert(result.SpecificationJson.Contains("\"code\": \"asset-folded-letter\"", StringComparison.Ordinal), "Specification JSON did not preserve the stable code."); Assert(result.SpecificationJson.Contains("\"code\": \"asset-folded-letter\"", StringComparison.Ordinal), "Specification JSON did not preserve the stable code.");
Assert(result.MetadataJson.Contains("generic-no-manuscript-data", StringComparison.Ordinal), "Metadata JSON did not preserve privacy metadata."); Assert(result.MetadataJson.Contains("generic-no-manuscript-data", StringComparison.Ordinal), "Metadata JSON did not preserve privacy metadata.");
@ -427,6 +433,23 @@ static void IllustrationStarterGenerationPlanSkipsSuccessfulItems()
Assert(IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(failedAsset.StableCode, [generatedCharacter, failedAsset]), "Failed starter should be eligible."); Assert(IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(failedAsset.StableCode, [generatedCharacter, failedAsset]), "Failed starter should be eligible.");
} }
static void StoryIntelligencePrototypeUsesStarterIllustrationCodes()
{
var prototype = StoryIntelligenceExperiencePrototypeData.Build();
var codes = prototype.Scenes
.SelectMany(scene => scene.Characters.Select(character => character.LibraryCode)
.Concat([scene.Location.LibraryCode])
.Concat(scene.Assets.Select(asset => asset.LibraryCode)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
Assert(codes.Count > 0, "Prototype should carry explicit library codes.");
Assert(codes.All(code => IllustrationStarterBatchDefinition.StarterCodes.Contains(code)), "Prototype requested a stable code that is not in the starter library.");
Assert(prototype.Scenes.SelectMany(scene => scene.Characters).All(character => character.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Character SVG fallback should remain before library resolution.");
Assert(prototype.Scenes.All(scene => scene.Location.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Location SVG fallback should remain before library resolution.");
Assert(prototype.Scenes.SelectMany(scene => scene.Assets).All(asset => asset.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Asset SVG fallback should remain before library resolution.");
}
static void IllustrationBulkRetryResultReportsCounts() static void IllustrationBulkRetryResultReportsCounts()
{ {
var result = new IllustrationBulkRetryResult(12, 4, 3); var result = new IllustrationBulkRetryResult(12, 4, 3);

View File

@ -16,7 +16,7 @@ public interface IIllustrationPromptBuilder
public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
{ {
public const string CurrentTemplateVersion = "21D.1"; public const string CurrentTemplateVersion = "21D.2";
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
@ -56,12 +56,17 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
Requirements: Requirements:
- representative visual identifier only, not a canonical depiction of any manuscript - representative visual identifier only, not a canonical depiction of any manuscript
- editorial illustration, artistic sketch or painted concept art, stylised realism - modern cinematic digital illustration with premium narrative game concept art quality
- sophisticated and calm, suitable for a dark navy interface - vivid natural colour, contemporary polished rendering, realistic anatomy and believable proportions
- calm cinematic lighting and clear silhouette - bright balanced lighting, clear facial illumination where faces are present, and strong local contrast
- crisp subject definition with strong foreground-background separation and clean readable silhouettes
- natural but richer colour with bright blues, reds, greens, and warm skin tones where appropriate
- polished promotional artwork for a premium story-driven adventure game, not a literal photograph
- subtle depth of field, clean modern rendering, and enough exposure to remain clear inside PlotDirector's dark navy interface
- no visible text, captions, logos, watermarks, UI, signatures, or typography - no visible text, captions, logos, watermarks, UI, signatures, or typography
- no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim - no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim
- avoid private manuscript details, real names, excerpts, or recognisable copyrighted characters - avoid private manuscript details, real names, excerpts, or recognisable copyrighted characters
- avoid muddy olive and brown grading, heavy sepia tones, underexposure, excessive darkness, low-contrast lighting, flat colour, antique oil-painting texture, distressed canvas texture, rough brushwork, overly sombre default expressions, backgrounds that merge into the subject, and monochrome or near-monochrome results
- square composition that still reads clearly as a small thumbnail - square composition that still reads clearly as a small thumbnail
"""; """;
@ -71,9 +76,9 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
private static string CategoryRules(string category) private static string CategoryRules(string category)
=> category switch => category switch
{ {
IllustrationLibraryCategories.Character => "Square image; head-and-shoulders or upper body; one person only; face large and readable; subdued background; suitable for circular cropping.", IllustrationLibraryCategories.Character => "Square image; head-and-shoulders or upper body; one person only; face-forward enough to read at small size; naturally expressive; cleanly lit face; distinct from background; suitable for circular cropping; avoid dark collars vanishing into black backgrounds.",
IllustrationLibraryCategories.Location => "One clear focal environment; usable in circular and landscape crops; no prominent people; no text or readable signage.", IllustrationLibraryCategories.Location => "One clear focal environment; vivid and readable; clearly lit and visually inviting even when dramatic; rich colour and depth; strong composition for circular and landscape crops; no prominent people; no text or readable signage; do not default every location to nighttime, rain-soaked, blue-black, ominous, or haunted-looking.",
IllustrationLibraryCategories.Asset => "Square image; one dominant object; recognisable silhouette; clean subdued background; entire object visible where practical.", IllustrationLibraryCategories.Asset => "Square image; one dominant centred object; bright clean high-contrast presentation; crisp silhouette; clear separation from background; immediately recognisable at small size; entire object visible where practical.",
_ => string.Empty _ => string.Empty
}; };
@ -99,34 +104,34 @@ public static class IllustrationStarterBatchDefinition
{ {
public static IReadOnlyList<IllustrationGenerationSpecification> All { get; } = public static IReadOnlyList<IllustrationGenerationSpecification> All { get; } =
[ [
Character("char-young-adult-brunette-observer", "Young observer", "A young adult with dark hair, thoughtful expression and practical coat.", "three-quarter portrait with soft rim light", "watchful, resilient", ["teal shadows", "warm amber accent"]), Character("char-young-adult-brunette-observer", "Young observer", "A young adult with dark hair, thoughtful expression and practical coat.", "three-quarter portrait with bright rim light and clear facial detail", "watchful, resilient", ["rich teal", "warm amber", "clear skin tones"]),
Character("char-young-adult-red-haired-witness", "Red-haired witness", "A young adult with auburn hair and guarded warmth.", "portrait facing slightly away with alert eyes", "protective, uncertain", ["midnight blue", "copper", "muted rose"]), Character("char-young-adult-red-haired-witness", "Red-haired witness", "A young adult with auburn hair and guarded warmth.", "face-readable portrait with alert eyes and clean background separation", "protective, uncertain", ["deep blue", "copper", "vivid rose"]),
Character("char-middle-aged-weathered-man", "Weathered man", "A middle-aged man with lined features and controlled tension.", "close portrait, collar raised, low directional light", "withheld, imposing", ["charcoal", "steel blue", "dull gold"]), Character("char-middle-aged-weathered-man", "Weathered man", "A middle-aged man with lined features and controlled tension.", "close portrait, collar raised, bright directional key light", "withheld, imposing", ["graphite", "steel blue", "bright brass"]),
Character("char-older-soft-presence", "Older remembered presence", "An older woman with gentle features and a private sadness.", "soft portrait like a remembered photograph", "warm, elegiac", ["soft grey", "dusty green", "warm ivory"]), Character("char-older-soft-presence", "Older remembered presence", "An older woman with gentle features and private warmth.", "clean bright portrait with soft contemporary depth of field", "warm, reflective", ["pearl grey", "fresh green", "warm ivory"]),
Character("char-professional-investigator", "Professional investigator", "A composed adult investigator with notebook and steady focus.", "waist-up portrait, neutral background", "analytical, calm", ["navy", "cream", "oxide red"]), Character("char-professional-investigator", "Professional investigator", "A composed adult investigator with notebook and steady focus.", "waist-up portrait, neutral background", "analytical, calm", ["navy", "cream", "oxide red"]),
Character("char-neighbourhood-friend", "Neighbourhood friend", "A friendly neighbour figure with practical clothes and open posture.", "casual portrait in corridor light", "helpful, cautious", ["sage", "denim", "soft yellow"]), Character("char-neighbourhood-friend", "Neighbourhood friend", "A friendly neighbour figure with practical clothes and open posture.", "casual portrait in corridor light", "helpful, cautious", ["sage", "denim", "soft yellow"]),
Character("char-family-relative", "Family relative", "A relative figure carrying old emotional context.", "portrait with subdued domestic background", "familial, layered", ["plum", "moss", "paper white"]), Character("char-family-relative", "Family relative", "A relative figure carrying old emotional context.", "portrait with clean domestic background separation", "familial, layered", ["plum", "fresh moss", "paper white"]),
Character("char-quiet-antagonist", "Quiet antagonist", "A controlled figure whose stillness suggests pressure.", "shadowed portrait with crisp edge light", "tense, unreadable", ["black", "deep green", "brass"]), Character("char-quiet-antagonist", "Quiet antagonist", "A controlled figure whose stillness suggests pressure.", "bright-edged portrait with visible facial structure and crisp outline", "tense, unreadable", ["ink black", "deep green", "brass"]),
Character("char-young-helper", "Young helper", "A younger helper figure with attentive expression.", "bright side-lit portrait", "curious, supportive", ["sky blue", "warm grey", "coral"]), Character("char-young-helper", "Young helper", "A younger helper figure with attentive expression.", "bright side-lit portrait", "curious, supportive", ["sky blue", "warm grey", "coral"]),
Character("char-authorial-voice", "Recorded voice", "A character represented as memory and voice rather than direct presence.", "portrait dissolving into abstract sound-like light", "intimate, distant", ["indigo", "silver", "warm white"]), Character("char-authorial-voice", "Recorded voice", "A character represented as memory and voice rather than direct presence.", "portrait dissolving into abstract sound-like light", "intimate, distant", ["indigo", "silver", "warm white"]),
Character("char-archival-contact", "Archival contact", "An older archive or records contact with patient authority.", "portrait with shelves suggested behind", "knowledgeable, reserved", ["umber", "slate", "muted gold"]), Character("char-archival-contact", "Archival contact", "An older archive or records contact with patient authority.", "portrait with shelves suggested behind and a bright readable face", "knowledgeable, reserved", ["warm sienna", "slate blue", "bright gold"]),
Character("char-unknown-figure", "Unknown figure", "A partly obscured person used for unresolved identity.", "silhouette with soft facial detail, no menace caricature", "ambiguous, suspenseful", ["deep blue", "smoke grey", "amber"]), Character("char-unknown-figure", "Unknown figure", "A partly obscured person used for unresolved identity.", "silhouette with soft facial detail, no menace caricature", "ambiguous, suspenseful", ["deep blue", "smoke grey", "amber"]),
Location("loc-estate-house-exterior", "Estate house exterior", "A rain-darkened old house or converted estate building.", "wide square view with lit windows and wet path", "quiet, secretive", ["blue black", "window gold", "wet stone"]), Location("loc-estate-house-exterior", "Estate house exterior", "An old house or converted estate building with an intriguing entrance.", "wide square view with readable architecture, glowing windows and clear path", "quiet, secretive", ["clear navy", "window gold", "wet stone highlights"]),
Location("loc-small-flat-interior", "Small flat interior", "A modest lived-in flat with signs of careful searching.", "room corner with desk, lamp and disturbed papers", "intimate, investigative", ["olive", "warm paper", "deep blue"]), Location("loc-small-flat-interior", "Small flat interior", "A modest lived-in flat with signs of careful searching.", "room corner with desk, lamp and disturbed papers, clean readable light", "intimate, investigative", ["fresh green", "warm paper", "clear blue"]),
Location("loc-laundry-utility-room", "Laundry utility room", "A low-lit utility or laundry room with domestic machinery.", "central washer, small window, practical clutter", "private, compressed", ["cool blue", "ceramic white", "rust accent"]), Location("loc-laundry-utility-room", "Laundry utility room", "A utility or laundry room with domestic machinery.", "central washer, small window, practical clutter, bright subject separation", "private, compressed", ["clean blue", "ceramic white", "rust accent"]),
Location("loc-narrow-stairwell", "Narrow stairwell", "A cramped stairwell or landing inside an older building.", "looking down the stairs with rail shadows", "pressured, transitional", ["slate", "old cream", "sodium amber"]), Location("loc-narrow-stairwell", "Narrow stairwell", "A cramped stairwell or landing inside an older building.", "looking down the stairs with rail shadows", "pressured, transitional", ["slate", "old cream", "sodium amber"]),
Location("loc-wet-car-park", "Wet car park", "A small wet car park with one clear exit route.", "low view of wet tarmac and lights", "exposed, tense", ["asphalt", "red reflection", "cold blue"]), Location("loc-wet-car-park", "Wet car park", "A small wet car park with one clear exit route.", "low view of wet tarmac with vivid reflections and bright readable lighting", "exposed, tense", ["asphalt grey", "red reflection", "clear blue"]),
Location("loc-domestic-desk", "Domestic desk", "A desk or table where clues are assembled.", "top-down three-quarter view of desk surface", "focused, revelatory", ["walnut", "paper white", "lamp gold"]), Location("loc-domestic-desk", "Domestic desk", "A desk or table where clues are assembled.", "top-down three-quarter view of desk surface", "focused, revelatory", ["walnut", "paper white", "lamp gold"]),
Asset("asset-folded-letter", "Folded letter", "A folded handwritten-looking letter as generic evidence.", "single object on table, envelope nearby, no readable text", "decisive, delicate", ["paper cream", "sepia", "shadow blue"]), Asset("asset-folded-letter", "Folded letter", "A folded handwritten-looking letter as generic evidence.", "single bright object on table, envelope nearby, no readable text", "decisive, delicate", ["paper cream", "warm gold", "clear blue"]),
Asset("asset-worn-notebook", "Worn notebook", "A worn notebook with a torn page edge.", "notebook angled on dark surface, blank pages", "investigative, intimate", ["brown leather", "cream paper", "blue shadow"]), Asset("asset-worn-notebook", "Worn notebook", "A worn notebook with a torn page edge.", "notebook angled on clean contrasting surface, blank pages", "investigative, intimate", ["rich leather", "cream paper", "clear blue"]),
Asset("asset-red-classic-car", "Red classic car", "A small red classic sports car used as a reusable vehicle asset.", "three-quarter view with wet reflections, no badges or plates", "charged, mobile", ["deep red", "chrome grey", "night blue"]), Asset("asset-red-classic-car", "Red classic car", "A small red classic sports car used as a reusable vehicle asset.", "three-quarter view with wet reflections, no badges or plates", "charged, mobile", ["deep red", "chrome grey", "night blue"]),
Asset("asset-old-keys", "Old keys", "A small bunch of old keys on a tag.", "close object study with strong silhouette", "access, possibility", ["brass", "graphite", "warm white"]), Asset("asset-old-keys", "Old keys", "A small bunch of old keys on a tag.", "close object study with strong silhouette", "access, possibility", ["brass", "graphite", "warm white"]),
Asset("asset-rucksack", "Rucksack", "A moved rucksack or soft bag.", "bag on floor near doorway", "recent movement, absence", ["canvas green", "brown", "cool shadow"]), Asset("asset-rucksack", "Rucksack", "A moved rucksack or soft bag.", "bag on floor near doorway with crisp outline", "recent movement, absence", ["canvas green", "warm tan", "clean blue"]),
Asset("asset-phone-recorder", "Phone recording", "A phone or recorder playing an audio message.", "device glowing on table, no visible text", "intimate, urgent", ["black glass", "blue glow", "warm tabletop"]), Asset("asset-phone-recorder", "Phone recording", "A phone or recorder playing an audio message.", "device glowing on table, no visible text", "intimate, urgent", ["black glass", "blue glow", "warm tabletop"]),
Asset("asset-torn-page", "Torn page", "A torn page fragment with no readable writing.", "paper fragment under lamplight", "partial evidence", ["paper white", "sepia", "ink blue"]), Asset("asset-torn-page", "Torn page", "A torn page fragment with no readable writing.", "paper fragment under bright warm lamplight", "partial evidence", ["paper white", "warm gold", "ink blue"]),
Asset("asset-locked-box", "Locked box", "A small locked keepsake or document box.", "box with latch in soft light", "contained secret", ["dark wood", "brass", "cool grey"]), Asset("asset-locked-box", "Locked box", "A small locked keepsake or document box.", "box with latch in soft light", "contained secret", ["dark wood", "brass", "cool grey"]),
Asset("asset-photo-print", "Photo print", "A generic photograph print with indistinct figures.", "photo on desk with intentionally unreadable detail", "memory, proof", ["faded colour", "cream border", "shadow"]), Asset("asset-photo-print", "Photo print", "A generic photograph print with indistinct figures.", "photo on desk with intentionally unreadable detail and bright edge separation", "memory, proof", ["vintage colour", "cream border", "clean graphite"]),
Asset("asset-map-pin", "Map clue", "A simple map or route clue with no readable labels.", "folded map with pin-like marker, no text", "connection, route", ["muted green", "paper tan", "red accent"]) Asset("asset-map-pin", "Map clue", "A simple map or route clue with no readable labels.", "folded map with pin-like marker, no text", "connection, route", ["fresh green", "paper tan", "red accent"])
]; ];
public static IReadOnlySet<string> StarterCodes { get; } = All.Select(item => item.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); public static IReadOnlySet<string> StarterCodes { get; } = All.Select(item => item.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
@ -592,7 +597,8 @@ public interface IIllustrationLibraryService
public sealed class IllustrationLibraryService( public sealed class IllustrationLibraryService(
IIllustrationLibraryRepository repository, IIllustrationLibraryRepository repository,
IIllustrationPromptBuilder promptBuilder, IIllustrationPromptBuilder promptBuilder,
IIllustrationImageProvider provider) : IIllustrationLibraryService IIllustrationImageProvider provider,
IUploadStorageService uploadStorage) : IIllustrationLibraryService
{ {
private const int MaxGenerationAttempts = 3; private const int MaxGenerationAttempts = 3;
@ -894,34 +900,109 @@ public sealed class IllustrationLibraryService(
var items = await repository.ListAsync(new IllustrationLibraryFilter()); var items = await repository.ListAsync(new IllustrationLibraryFilter());
var lookup = items var lookup = items
.Where(item => IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode)) .Where(item => IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode))
.Where(item => item.FilePath is not null && item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated) .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
.OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1) .OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1)
.ThenByDescending(item => item.UpdatedUtc) .ThenByDescending(item => item.UpdatedUtc)
.GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase) .GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().FilePath!, StringComparer.OrdinalIgnoreCase); .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var scene in prototype.Scenes) foreach (var scene in prototype.Scenes)
{ {
if (!string.IsNullOrWhiteSpace(scene.Location.LibraryCode) && lookup.TryGetValue(scene.Location.LibraryCode, out var locationPath)) var locationFallback = scene.Location.ImagePath;
{ var locationResolution = ResolvePrototypeImage(scene.Location.Id, scene.Location.LibraryCode, locationFallback, lookup);
scene.Location.ImagePath = locationPath; scene.Location.ImageResolution = locationResolution;
} scene.Location.ImagePath = locationResolution.FinalImageUrl ?? locationFallback;
foreach (var character in scene.Characters) foreach (var character in scene.Characters)
{ {
if (!string.IsNullOrWhiteSpace(character.LibraryCode) && lookup.TryGetValue(character.LibraryCode, out var characterPath)) var fallback = character.ImagePath;
{ var resolution = ResolvePrototypeImage(character.Id, character.LibraryCode, fallback, lookup);
character.ImagePath = characterPath; character.ImageResolution = resolution;
} character.ImagePath = resolution.FinalImageUrl ?? fallback;
} }
foreach (var asset in scene.Assets) foreach (var asset in scene.Assets)
{ {
if (!string.IsNullOrWhiteSpace(asset.LibraryCode) && lookup.TryGetValue(asset.LibraryCode, out var assetPath)) var fallback = asset.ImagePath;
var resolution = ResolvePrototypeImage(asset.Id, asset.LibraryCode, fallback, lookup);
asset.ImageResolution = resolution;
asset.ImagePath = resolution.FinalImageUrl ?? fallback;
}
}
}
private StoryIntelligenceExperienceImageResolution ResolvePrototypeImage(
string entityId,
string stableCode,
string fallbackImageUrl,
IReadOnlyDictionary<string, List<IllustrationLibraryItem>> lookup)
{ {
asset.ImagePath = assetPath; if (string.IsNullOrWhiteSpace(stableCode) || !lookup.TryGetValue(stableCode, out var candidates))
{
return PrototypeFallback(entityId, stableCode, fallbackImageUrl);
} }
foreach (var candidate in candidates)
{
var publicUrl = ResolveExistingPublicUploadUrl(candidate.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.FilePath);
if (publicUrl is null)
{
continue;
} }
return new()
{
EntityId = entityId,
StableCode = stableCode,
IllustrationLibraryItemId = candidate.IllustrationLibraryItemID,
Status = candidate.Status,
FinalImageUrl = publicUrl,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = false
};
} }
var firstCandidate = candidates.FirstOrDefault();
return new()
{
EntityId = entityId,
StableCode = stableCode,
IllustrationLibraryItemId = firstCandidate?.IllustrationLibraryItemID,
Status = firstCandidate?.Status,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = true
};
} }
private string? ResolveExistingPublicUploadUrl(string? storedPath)
{
var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library");
if (physicalPath is null || !File.Exists(physicalPath))
{
return null;
}
var normalised = storedPath!.Replace('\\', '/').Trim();
if (!normalised.StartsWith('/'))
{
normalised = "/" + normalised.TrimStart('/');
}
return EncodeUrlPath(normalised);
}
private static StoryIntelligenceExperienceImageResolution PrototypeFallback(string entityId, string stableCode, string fallbackImageUrl)
=> new()
{
EntityId = entityId,
StableCode = stableCode,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = true
};
private static string EncodeUrlPath(string path)
=> string.Join(
'/',
path.Split('/', StringSplitOptions.None)
.Select((segment, index) => index == 0 && segment.Length == 0 ? string.Empty : Uri.EscapeDataString(segment)));
} }

View File

@ -42,6 +42,7 @@ public sealed class StoryIntelligenceExperienceCharacterState
public string Relevance { get; init; } = string.Empty; public string Relevance { get; init; } = string.Empty;
public int Weight { get; init; } public int Weight { get; init; }
public string ImagePath { get; set; } = string.Empty; public string ImagePath { get; set; } = string.Empty;
public StoryIntelligenceExperienceImageResolution ImageResolution { get; set; } = new();
} }
public sealed class StoryIntelligenceExperienceLocationState public sealed class StoryIntelligenceExperienceLocationState
@ -51,6 +52,7 @@ public sealed class StoryIntelligenceExperienceLocationState
public string Name { get; init; } = string.Empty; public string Name { get; init; } = string.Empty;
public string Label { get; init; } = string.Empty; public string Label { get; init; } = string.Empty;
public string ImagePath { get; set; } = string.Empty; public string ImagePath { get; set; } = string.Empty;
public StoryIntelligenceExperienceImageResolution ImageResolution { get; set; } = new();
} }
public sealed class StoryIntelligenceExperienceAssetState public sealed class StoryIntelligenceExperienceAssetState
@ -61,6 +63,18 @@ public sealed class StoryIntelligenceExperienceAssetState
public string Label { get; init; } = string.Empty; public string Label { get; init; } = string.Empty;
public int Weight { get; init; } public int Weight { get; init; }
public string ImagePath { get; set; } = string.Empty; public string ImagePath { get; set; } = string.Empty;
public StoryIntelligenceExperienceImageResolution ImageResolution { get; set; } = new();
}
public sealed class StoryIntelligenceExperienceImageResolution
{
public string EntityId { get; init; } = string.Empty;
public string StableCode { get; init; } = string.Empty;
public int? IllustrationLibraryItemId { get; init; }
public string? Status { get; init; }
public string? FinalImageUrl { get; init; }
public string FallbackImageUrl { get; init; } = string.Empty;
public bool FallbackUsed { get; init; } = true;
} }
public sealed class StoryIntelligenceExperienceRelationshipState public sealed class StoryIntelligenceExperienceRelationshipState

View File

@ -15,7 +15,7 @@
<body> <body>
<script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(Model, jsonOptions))</script> <script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(Model, jsonOptions))</script>
<main class="story-exp" data-story-experience> <main class="story-exp" data-story-experience data-image-diagnostics="true">
<header class="story-exp-header" aria-label="Story Intelligence status"> <header class="story-exp-header" aria-label="Story Intelligence status">
<div class="story-exp-brand"> <div class="story-exp-brand">
<strong>PlotDirector</strong> <strong>PlotDirector</strong>

View File

@ -496,33 +496,36 @@ input:focus-visible {
.story-exp-stage { .story-exp-stage {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr) 158px; grid-template-rows: auto minmax(0, 1fr) 154px;
gap: 10px; gap: 8px;
min-height: 0; min-height: 0;
} }
.story-exp-stage-copy { .story-exp-stage-copy {
display: grid; display: grid;
grid-template-columns: 1fr minmax(240px, 0.72fr); grid-template-columns: minmax(260px, 0.64fr) 1fr;
align-items: end; align-items: center;
gap: 22px; gap: 18px;
min-height: 62px; min-height: 42px;
} }
.story-exp-stage-copy h1 { .story-exp-stage-copy h1 {
grid-column: 1; grid-column: 1;
margin: 0; margin: 0;
font-size: clamp(1.55rem, 2.35vw, 2.75rem); overflow: hidden;
line-height: 1.05; font-size: clamp(1rem, 1.2vw, 1.34rem);
line-height: 1.12;
letter-spacing: 0; letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
} }
.story-exp-stage-copy p:last-child { .story-exp-stage-copy p:last-child {
grid-column: 2; grid-column: 2;
margin: 0; margin: 0;
color: var(--story-soft); color: var(--story-soft);
font-size: clamp(0.86rem, 1vw, 1rem); font-size: clamp(0.78rem, 0.86vw, 0.92rem);
line-height: 1.38; line-height: 1.28;
} }
.story-exp-visual { .story-exp-visual {
@ -565,51 +568,57 @@ input:focus-visible {
} }
.story-exp-connection { .story-exp-connection {
stroke: rgba(215, 168, 93, 0.5); stroke: rgba(215, 168, 93, 0.48);
stroke-linecap: round; stroke-linecap: round;
stroke-width: 1.8; stroke-width: 1.8;
stroke-dasharray: 1; stroke-dasharray: 1;
stroke-dashoffset: 1; stroke-dashoffset: 1;
fill: none; fill: none;
filter: drop-shadow(0 0 7px rgba(215, 168, 93, 0.22)); filter: drop-shadow(0 0 5px rgba(215, 168, 93, 0.18));
animation: drawLine 950ms cubic-bezier(.16, .84, .28, 1) forwards; animation: drawLine 760ms cubic-bezier(.16, .84, .28, 1) forwards;
} }
.story-exp-connection-group--presence .story-exp-connection, .story-exp-connection-group--presence .story-exp-connection,
.story-exp-connection-group--presence-soft .story-exp-connection { .story-exp-connection-group--presence-soft .story-exp-connection {
stroke: rgba(125, 193, 255, 0.58); stroke: rgba(125, 193, 255, 0.52);
} }
.story-exp-connection-group--presence-soft .story-exp-connection, .story-exp-connection-group--presence-soft .story-exp-connection,
.story-exp-connection-group--knowledge .story-exp-connection, .story-exp-connection-group--knowledge .story-exp-connection,
.story-exp-connection-group--asset-link .story-exp-connection { .story-exp-connection-group--asset-link .story-exp-connection {
stroke-dasharray: 0.12 0.08; stroke-dasharray: 0.16 0.12;
} }
.story-exp-connection-group--support .story-exp-connection { .story-exp-connection-group--support .story-exp-connection {
stroke: rgba(121, 210, 166, 0.64); stroke: rgba(121, 210, 166, 0.58);
} }
.story-exp-connection-group--conflict .story-exp-connection { .story-exp-connection-group--conflict .story-exp-connection {
stroke: rgba(227, 140, 168, 0.76); stroke: rgba(227, 140, 168, 0.7);
stroke-dasharray: 0.18 0.1; stroke-dasharray: 0.2 0.12;
} }
.story-exp-connection-group--uncertain .story-exp-connection { .story-exp-connection-group--uncertain .story-exp-connection {
stroke: rgba(182, 162, 255, 0.56); stroke: rgba(182, 162, 255, 0.48);
stroke-dasharray: 0.08 0.09; stroke-dasharray: 0.12 0.11;
} }
.story-exp-connection-group--knowledge .story-exp-connection { .story-exp-connection-group--knowledge .story-exp-connection {
stroke: rgba(182, 162, 255, 0.5); stroke: rgba(182, 162, 255, 0.44);
}
.story-exp-connection-label-bg {
fill: rgba(4, 10, 20, 0.72);
stroke: rgba(151, 185, 218, 0.12);
stroke-width: 1px;
} }
.story-exp-connection-label { .story-exp-connection-label {
paint-order: stroke; paint-order: stroke;
stroke: rgba(4, 10, 20, 0.86); stroke: rgba(4, 10, 20, 0.68);
stroke-width: 5px; stroke-width: 3px;
fill: var(--story-soft); fill: var(--story-soft);
font-size: 0.72rem; font-size: 0.66rem;
font-weight: 800; font-weight: 800;
letter-spacing: 0.01em; letter-spacing: 0.01em;
text-transform: lowercase; text-transform: lowercase;
@ -656,17 +665,22 @@ input:focus-visible {
.story-character { .story-character {
position: absolute; position: absolute;
display: grid; display: block;
gap: 8px;
width: var(--node-size, 150px); width: var(--node-size, 150px);
height: var(--node-size, 150px);
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1)); transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
opacity: var(--opacity, 1); opacity: var(--opacity, 1);
z-index: var(--z, 3);
pointer-events: auto; pointer-events: auto;
transition: transform 1050ms cubic-bezier(.16, .84, .28, 1), opacity 780ms ease; transition: transform 1050ms cubic-bezier(.16, .84, .28, 1), opacity 780ms ease;
} }
.story-character.is-pov { .story-character.is-pov {
z-index: 4; z-index: 7;
}
.story-character.is-context .story-character__image {
filter: saturate(0.82);
} }
.story-character__image, .story-character__image,
@ -699,21 +713,84 @@ input:focus-visible {
} }
.story-character__text { .story-character__text {
position: absolute;
top: calc(var(--node-size, 150px) + 9px);
left: 50%;
display: grid; display: grid;
justify-items: center; justify-items: center;
gap: 2px; gap: 2px;
width: min(176px, calc(var(--node-size, 150px) + 44px));
max-width: calc(100vw - 32px);
margin-left: 0;
border: 1px solid rgba(151, 185, 218, 0.1);
border-radius: 10px;
padding: 5px 7px 6px;
background: rgba(4, 10, 20, 0.58);
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(8px);
text-align: center; text-align: center;
transform: translateX(-50%);
}
.story-character.is-pov .story-character__text {
top: auto;
left: calc(50% + 34px);
bottom: calc(100% + 9px);
}
.story-character[data-layout-role="partner"] .story-character__text {
top: 50%;
left: calc(100% + 12px);
justify-items: start;
text-align: left;
transform: translateY(-50%);
}
.story-character[data-layout-role="support"] .story-character__text {
left: calc(100% + 10px);
justify-items: start;
text-align: left;
transform: translateY(0);
}
.story-character[data-layout-role="context"] .story-character__text {
width: min(132px, calc(var(--node-size, 150px) + 34px));
left: 50%;
top: calc(100% + 7px);
}
.story-character[data-label-side="left"] .story-character__text {
top: 50%;
left: auto;
right: calc(100% + 10px);
justify-items: end;
text-align: right;
transform: translateY(-50%);
} }
.story-character__text strong { .story-character__text strong {
font-size: 1rem; overflow: hidden;
max-width: 100%;
font-size: clamp(0.82rem, 0.9vw, 0.98rem);
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
} }
.story-character__text span { .story-character__text span {
max-width: 150px; overflow: hidden;
max-width: 100%;
color: var(--story-muted); color: var(--story-muted);
font-size: 0.76rem; font-size: 0.68rem;
font-weight: 700; font-weight: 700;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-character.is-context .story-character__text {
padding: 4px 6px;
opacity: 0.9;
} }
.story-location { .story-location {
@ -723,6 +800,7 @@ input:focus-visible {
gap: 10px; gap: 10px;
width: var(--node-size, 190px); width: var(--node-size, 190px);
transform: translate(var(--x, 0), var(--y, 0)); transform: translate(var(--x, 0), var(--y, 0));
z-index: 4;
pointer-events: auto; pointer-events: auto;
transition: opacity 560ms ease, transform 850ms cubic-bezier(.16, .84, .28, 1); transition: opacity 560ms ease, transform 850ms cubic-bezier(.16, .84, .28, 1);
} }
@ -745,15 +823,37 @@ input:focus-visible {
margin: 0; margin: 0;
} }
.story-location__text {
display: grid;
justify-items: center;
gap: 3px;
max-width: min(244px, calc(100vw - 32px));
border: 1px solid rgba(125, 193, 255, 0.12);
border-radius: 12px;
padding: 7px 10px;
background: rgba(4, 10, 20, 0.62);
box-shadow: 0 16px 34px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(9px);
}
.story-location h2 { .story-location h2 {
overflow: hidden;
max-width: 100%;
font-size: clamp(1.12rem, 1.55vw, 1.55rem); font-size: clamp(1.12rem, 1.55vw, 1.55rem);
text-align: center; text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
} }
.story-location p { .story-location p {
overflow: hidden;
max-width: 100%;
color: var(--story-muted); color: var(--story-muted);
font-size: 0.86rem; font-size: 0.78rem;
line-height: 1.15;
text-align: center; text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
} }
.story-exp-assets { .story-exp-assets {
@ -766,37 +866,54 @@ input:focus-visible {
position: absolute; position: absolute;
display: grid; display: grid;
justify-items: center; justify-items: center;
gap: 7px; gap: 6px;
width: max(var(--node-size, 86px), 112px); width: max(var(--node-size, 78px), 104px);
opacity: var(--opacity, 1); opacity: var(--opacity, 1);
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1)); transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
z-index: var(--z, 3);
pointer-events: auto; pointer-events: auto;
transition: transform 860ms cubic-bezier(.16, .84, .28, 1), opacity 620ms ease; transition: transform 860ms cubic-bezier(.16, .84, .28, 1), opacity 620ms ease;
} }
.story-asset__image { .story-asset__image {
width: var(--node-size, 86px); width: var(--node-size, 78px);
height: var(--node-size, 86px); height: var(--node-size, 78px);
border-radius: 50%; border-radius: 50%;
border-color: rgba(215, 168, 93, 0.68); border-color: rgba(215, 168, 93, 0.68);
box-shadow: 0 0 0 5px rgba(215, 168, 93, 0.08), 0 22px 48px rgba(0, 0, 0, 0.3), 0 0 28px rgba(215, 168, 93, 0.14); box-shadow: 0 0 0 4px rgba(215, 168, 93, 0.08), 0 18px 38px rgba(0, 0, 0, 0.28), 0 0 24px rgba(215, 168, 93, 0.13);
} }
.story-asset__text { .story-asset__text {
display: grid; display: grid;
justify-items: center; justify-items: center;
gap: 2px; gap: 2px;
width: min(140px, calc(var(--node-size, 78px) + 52px));
border: 1px solid rgba(215, 168, 93, 0.11);
border-radius: 10px;
padding: 4px 7px 5px;
background: rgba(4, 10, 20, 0.6);
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(8px);
text-align: center; text-align: center;
} }
.story-asset strong, .story-asset strong,
.story-asset span { .story-asset span {
display: block; display: block;
overflow: hidden;
max-width: 100%;
line-height: 1.08;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-asset strong {
font-size: 0.78rem;
} }
.story-asset span { .story-asset span {
color: var(--story-muted); color: var(--story-muted);
font-size: 0.76rem; font-size: 0.64rem;
} }
.story-exp-ribbon-wrap { .story-exp-ribbon-wrap {

View File

@ -8,11 +8,14 @@
if (!scenes.length) return; if (!scenes.length) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const failedImageUrls = new Set();
const preloadedImageUrls = new Set();
const state = { const state = {
index: 0, index: 0,
playing: true, playing: true,
intervalMs: 6200, intervalMs: 6200,
timer: null timer: null,
drawRequest: null
}; };
const analysisStages = [ const analysisStages = [
{ key: "reading", label: "Reading" }, { key: "reading", label: "Reading" },
@ -65,13 +68,17 @@
const previous = loadSceneState(state.index); const previous = loadSceneState(state.index);
state.index = Math.max(0, Math.min(scenes.length - 1, nextIndex)); state.index = Math.max(0, Math.min(scenes.length - 1, nextIndex));
const current = loadSceneState(state.index); const current = loadSceneState(state.index);
preloadSceneImages(current);
preloadSceneImages(loadSceneState(Math.min(scenes.length - 1, state.index + 1)));
logImageDiagnostics(current);
updateProgress(current); updateProgress(current);
updateAnalysisStages(current); updateAnalysisStages(current);
updateSceneCopy(current); updateSceneCopy(current);
reconcileCharacters(current, previous); const layout = sceneLayout(current);
reconcileCharacters(current, previous, layout);
reconcileLocation(current, previous); reconcileLocation(current, previous);
reconcileAssets(current, previous); reconcileAssets(current, previous, layout);
reconcileRelationships(current); reconcileRelationships(current);
updateKnowledgeThreads(current); updateKnowledgeThreads(current);
updateSceneRibbon(current); updateSceneRibbon(current);
@ -124,11 +131,74 @@
} }
function updateSceneCopy(scene) { function updateSceneCopy(scene) {
text(dom.sceneTitle, scene.title); text(dom.sceneTitle, `Scene ${scene.sceneNumber}${scene.title}`);
text(dom.sceneSummary, scene.summary); text(dom.sceneSummary, scene.summary);
} }
function reconcileCharacters(scene) { function setEntityImage(image, url, resolution, altText) {
if (!image) return;
image.alt = altText;
const fallbackUrl = resolution?.fallbackImageUrl || url;
const nextUrl = failedImageUrls.has(url) ? fallbackUrl : url;
image.dataset.fallbackUrl = fallbackUrl || "";
image.dataset.entityId = resolution?.entityId || "";
image.dataset.libraryCode = resolution?.stableCode || "";
image.onerror = () => {
const fallback = image.dataset.fallbackUrl;
if (!fallback || image.src.endsWith(fallback)) return;
failedImageUrls.add(url);
image.src = fallback;
scheduleConnectionDraw();
};
image.onload = () => scheduleConnectionDraw();
if (image.getAttribute("src") !== nextUrl) {
image.src = nextUrl;
}
}
function preloadSceneImages(scene) {
if (!scene) return;
collectSceneImages(scene).forEach((url) => {
if (!url || preloadedImageUrls.has(url) || failedImageUrls.has(url)) return;
preloadedImageUrls.add(url);
const image = new Image();
image.onload = () => {};
image.onerror = () => failedImageUrls.add(url);
image.src = url;
});
}
function collectSceneImages(scene) {
return [
scene.location?.imagePath,
...(scene.characters || []).map((character) => character.imagePath),
...(scene.assets || []).map((asset) => asset.imagePath)
].filter(Boolean);
}
function logImageDiagnostics(scene) {
if (!root.dataset.imageDiagnostics) return;
const rows = [];
const push = (type, item) => {
const resolution = item?.imageResolution || {};
rows.push({
type,
entityId: item?.id,
stableCode: resolution.stableCode || item?.libraryCode || "",
recordId: resolution.illustrationLibraryItemId || null,
status: resolution.status || "fallback",
finalImageUrl: resolution.finalImageUrl || item?.imagePath || "",
fallbackUsed: !!resolution.fallbackUsed
});
};
push("location", scene.location);
(scene.characters || []).forEach((character) => push("character", character));
(scene.assets || []).forEach((asset) => push("asset", asset));
console.table(rows);
}
function reconcileCharacters(scene, previous, layout) {
const activeIds = new Set(scene.characters.map((character) => character.id)); const activeIds = new Set(scene.characters.map((character) => character.id));
scene.characters.forEach((character, index) => { scene.characters.forEach((character, index) => {
let node = dom.characters.querySelector(`[data-character-id="${cssEscape(character.id)}"]`); let node = dom.characters.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
@ -137,17 +207,23 @@
dom.characters.append(node); dom.characters.append(node);
} }
const position = characterPosition(character, index, scene.povCharacterId); const position = layout.characters.get(character.id) || characterPosition(character, index, scene.povCharacterId, scene.characters);
node.classList.toggle("is-pov", character.id === scene.povCharacterId); node.classList.toggle("is-pov", character.id === scene.povCharacterId);
node.classList.toggle("is-secondary", character.id !== scene.povCharacterId && character.weight >= 65);
node.classList.toggle("is-context", character.id !== scene.povCharacterId && character.weight < 65);
node.style.setProperty("--node-size", `${position.size}px`); node.style.setProperty("--node-size", `${position.size}px`);
node.style.setProperty("--x", `${position.x}px`); node.style.setProperty("--x", `${position.x}px`);
node.style.setProperty("--y", `${position.y}px`); node.style.setProperty("--y", `${position.y}px`);
node.style.setProperty("--scale", String(position.scale)); node.style.setProperty("--scale", String(position.scale));
node.style.setProperty("--opacity", String(position.opacity)); node.style.setProperty("--opacity", String(position.opacity));
node.querySelector("img").src = character.imagePath; node.style.setProperty("--z", String(position.z));
node.querySelector("img").alt = `${character.name} prototype portrait`; node.dataset.layoutRole = position.layoutRole;
node.dataset.labelSide = position.labelSide || "";
setEntityImage(node.querySelector("img"), character.imagePath, character.imageResolution, `${character.name} prototype portrait`);
text(node.querySelector("[data-character-name]"), character.name); text(node.querySelector("[data-character-name]"), character.name);
text(node.querySelector("[data-character-role]"), `${character.role} - ${character.relevance}`); const label = conciseCharacterLabel(character, character.id === scene.povCharacterId);
text(node.querySelector("[data-character-role]"), label);
node.title = `${character.name}: ${character.role}${character.relevance ? ` - ${character.relevance}` : ""}`;
}); });
dom.characters.querySelectorAll("[data-character-id]").forEach((node) => { dom.characters.querySelectorAll("[data-character-id]").forEach((node) => {
@ -183,15 +259,16 @@
node.style.setProperty("--node-size", `${position.size}px`); node.style.setProperty("--node-size", `${position.size}px`);
node.style.setProperty("--x", `${position.x}px`); node.style.setProperty("--x", `${position.x}px`);
node.style.setProperty("--y", `${position.y}px`); node.style.setProperty("--y", `${position.y}px`);
node.querySelector("img").src = scene.location.imagePath; setEntityImage(node.querySelector("img"), scene.location.imagePath, scene.location.imageResolution, `${scene.location.name} prototype location`);
text(node.querySelector("h2"), scene.location.name); text(node.querySelector("h2"), scene.location.name);
text(node.querySelector("p"), scene.location.label); text(node.querySelector("p"), conciseLocationLabel(scene.location));
node.title = `${scene.location.name}: ${scene.location.label}`;
dom.location.append(node); dom.location.append(node);
scheduleConnectionDraw(); scheduleConnectionDraw();
}, changed && !reducedMotion ? 260 : 0); }, changed && !reducedMotion ? 260 : 0);
} }
function reconcileAssets(scene) { function reconcileAssets(scene, previous, layout) {
const activeIds = new Set(scene.assets.map((asset) => asset.id)); const activeIds = new Set(scene.assets.map((asset) => asset.id));
scene.assets.forEach((asset, index) => { scene.assets.forEach((asset, index) => {
let node = dom.assets.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`); let node = dom.assets.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
@ -200,16 +277,17 @@
dom.assets.append(node); dom.assets.append(node);
} }
const position = assetPosition(asset, index, scene.assets.length); const position = layout.assets.get(asset.id) || assetPosition(asset, index, scene.assets.length);
node.style.setProperty("--node-size", `${position.size}px`); node.style.setProperty("--node-size", `${position.size}px`);
node.style.setProperty("--x", `${position.x}px`); node.style.setProperty("--x", `${position.x}px`);
node.style.setProperty("--y", `${position.y}px`); node.style.setProperty("--y", `${position.y}px`);
node.style.setProperty("--scale", String(position.scale)); node.style.setProperty("--scale", String(position.scale));
node.style.setProperty("--z", String(position.z));
node.style.setProperty("--opacity", "1"); node.style.setProperty("--opacity", "1");
node.querySelector("img").src = asset.imagePath; setEntityImage(node.querySelector("img"), asset.imagePath, asset.imageResolution, `${asset.name} prototype asset`);
node.querySelector("img").alt = `${asset.name} prototype asset`;
text(node.querySelector("[data-asset-name]"), asset.name); text(node.querySelector("[data-asset-name]"), asset.name);
text(node.querySelector("[data-asset-label]"), asset.label); text(node.querySelector("[data-asset-label]"), conciseAssetLabel(asset));
node.title = `${asset.name}: ${asset.label}`;
}); });
dom.assets.querySelectorAll("[data-asset-id]").forEach((node) => { dom.assets.querySelectorAll("[data-asset-id]").forEach((node) => {
@ -325,7 +403,14 @@
} }
function scheduleConnectionDraw() { function scheduleConnectionDraw() {
window.requestAnimationFrame(() => drawConnections(loadSceneState(state.index))); if (state.drawRequest) {
window.cancelAnimationFrame(state.drawRequest);
}
state.drawRequest = window.requestAnimationFrame(() => {
state.drawRequest = null;
drawConnections(loadSceneState(state.index));
});
} }
function drawConnections(scene) { function drawConnections(scene) {
@ -333,6 +418,7 @@
const stageRect = dom.visualStage.getBoundingClientRect(); const stageRect = dom.visualStage.getBoundingClientRect();
dom.connections.innerHTML = ""; dom.connections.innerHTML = "";
const drawn = []; const drawn = [];
const occupiedRects = layoutRects(stageRect);
scene.relationships.forEach((relationship, index) => { scene.relationships.forEach((relationship, index) => {
const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`); const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`);
@ -341,67 +427,69 @@
const start = connectionPoint(source, stageRect, target); const start = connectionPoint(source, stageRect, target);
const end = connectionPoint(target, stageRect, source); const end = connectionPoint(target, stageRect, source);
drawn.push(drawConnection(start, end, relationshipTone(relationship), relationship.weight, index === 0 ? inlineRelationshipLabel(relationship) : "")); const obstacles = connectionObstacles(stageRect, source, target);
drawn.push(drawConnection(start, end, relationshipTone(relationship), relationship.weight, index < 2 ? inlineRelationshipLabel(relationship) : "", index, obstacles, occupiedRects));
}); });
const pov = dom.visualStage.querySelector(`[data-character-id="${cssEscape(scene.povCharacterId)}"]`); const pov = dom.visualStage.querySelector(`[data-character-id="${cssEscape(scene.povCharacterId)}"]`);
const location = dom.visualStage.querySelector("[data-location-id]"); const location = dom.visualStage.querySelector("[data-location-id]");
if (pov && location) { if (pov && location) {
drawn.push(drawConnection(connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 72, "")); drawn.push(drawConnection(connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 68, "", drawn.length, connectionObstacles(stageRect, pov, location), occupiedRects));
} }
scene.characters scene.characters
.filter((character) => character.id !== scene.povCharacterId && character.weight >= 65) .filter((character) => character.id !== scene.povCharacterId && character.weight >= 65)
.slice(0, 2) .slice(0, 1)
.forEach((character) => { .forEach((character) => {
const node = dom.visualStage.querySelector(`[data-character-id="${cssEscape(character.id)}"]`); const node = dom.visualStage.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
if (node && location) { if (node && location) {
drawn.push(drawConnection(connectionPoint(node, stageRect, location), connectionPoint(location, stageRect, node), "presence-soft", character.weight, "")); drawn.push(drawConnection(connectionPoint(node, stageRect, location), connectionPoint(location, stageRect, node), "presence-soft", character.weight, "", drawn.length, connectionObstacles(stageRect, node, location), occupiedRects));
} }
}); });
scene.assets.forEach((asset, index) => { scene.assets.forEach((asset, index) => {
const assetNode = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`); const assetNode = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
if (!assetNode || !location) return; if (!assetNode || !location) return;
drawn.push(drawConnection(connectionPoint(location, stageRect, assetNode), connectionPoint(assetNode, stageRect, location), "evidence", asset.weight, index === 0 ? assetConnectionLabel(asset) : "")); drawn.push(drawConnection(connectionPoint(location, stageRect, assetNode), connectionPoint(assetNode, stageRect, location), "evidence", asset.weight, index === 0 ? assetConnectionLabel(asset) : "", drawn.length, connectionObstacles(stageRect, location, assetNode), occupiedRects));
if (pov && index < 2) {
drawn.push(drawConnection(connectionPoint(pov, stageRect, assetNode), connectionPoint(assetNode, stageRect, pov), "knowledge", Math.max(45, asset.weight - 18), ""));
}
}); });
if (scene.assets.length > 1) {
const first = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(scene.assets[0].id)}"]`);
const second = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(scene.assets[1].id)}"]`);
if (first && second) {
drawn.push(drawConnection(connectionPoint(first, stageRect, second), connectionPoint(second, stageRect, first), "asset-link", 48, ""));
}
}
} }
function drawConnection(start, end, tone, weight, label) { function drawConnection(start, end, tone, weight, label, index, obstacles, occupiedRects) {
const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.setAttribute("class", `story-exp-connection-group story-exp-connection-group--${tone}`); group.setAttribute("class", `story-exp-connection-group story-exp-connection-group--${tone}`);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const dx = end.x - start.x; const route = routeConnection(start, end, index, obstacles);
const dy = end.y - start.y; const d = `M ${start.x} ${start.y} C ${route.startControl.x} ${route.startControl.y}, ${route.endControl.x} ${route.endControl.y}, ${end.x} ${end.y}`;
const curve = Math.max(34, Math.min(126, Math.abs(dx) * 0.24 + Math.abs(dy) * 0.1));
const bend = start.x < end.x ? curve : -curve;
const d = `M ${start.x} ${start.y} C ${start.x + bend} ${start.y - curve * 0.28}, ${end.x - bend} ${end.y + curve * 0.28}, ${end.x} ${end.y}`;
path.setAttribute("d", d); path.setAttribute("d", d);
path.setAttribute("pathLength", "1"); path.setAttribute("pathLength", "1");
path.setAttribute("class", "story-exp-connection"); path.setAttribute("class", "story-exp-connection");
path.style.strokeWidth = String(1.1 + Math.min(weight, 100) / 56); path.style.strokeWidth = String(0.9 + Math.min(weight, 100) / 62);
group.append(path); group.append(path);
if (label) { if (label) {
const labelPosition = relationshipLabelPosition(start, route, end, occupiedRects);
if (!labelPosition) {
dom.connections.append(group);
return group;
}
const labelGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
labelGroup.setAttribute("class", "story-exp-connection-label-group");
const labelNode = document.createElementNS("http://www.w3.org/2000/svg", "text"); const labelNode = document.createElementNS("http://www.w3.org/2000/svg", "text");
labelNode.setAttribute("class", "story-exp-connection-label"); labelNode.setAttribute("class", "story-exp-connection-label");
labelNode.setAttribute("x", String((start.x + end.x) / 2)); labelNode.setAttribute("x", String(labelPosition.x));
labelNode.setAttribute("y", String((start.y + end.y) / 2 - 8)); labelNode.setAttribute("y", String(labelPosition.y));
labelNode.setAttribute("text-anchor", "middle"); labelNode.setAttribute("text-anchor", "middle");
labelNode.textContent = label; labelNode.textContent = label;
group.append(labelNode); labelGroup.append(labelNode);
group.append(labelGroup);
occupiedRects.push({
left: labelPosition.x - 78,
right: labelPosition.x + 78,
top: labelPosition.y - 22,
bottom: labelPosition.y + 10
});
window.requestAnimationFrame(() => fitConnectionLabelBackground(labelGroup));
} }
dom.connections.append(group); dom.connections.append(group);
@ -434,10 +522,79 @@
return node; return node;
} }
function characterPosition(character, index, povId) { function sceneLayout(scene) {
const bounds = dom.visualStage?.getBoundingClientRect();
const width = Math.max(760, bounds?.width || 1040);
const height = Math.max(500, bounds?.height || 560);
return {
characters: characterLayout(scene, width, height),
assets: assetLayout(scene, width, height)
};
}
function characterLayout(scene, width, height) {
const positions = new Map();
const characters = [...scene.characters];
const pov = characters.find((character) => character.id === scene.povCharacterId) || characters[0];
const others = characters
.filter((character) => character.id !== pov?.id)
.sort((a, b) => b.weight - a.weight);
const povSize = Math.round(Math.min(198, Math.max(154, width * 0.155)));
if (pov) {
positions.set(pov.id, {
x: clamp(Math.round(width * 0.24 - povSize / 2), 28, width * 0.44 - povSize),
y: clamp(Math.round(height * 0.28 - povSize / 2), 54, height * 0.54 - povSize),
size: povSize,
scale: 1,
opacity: 1,
z: 7,
layoutRole: "pov",
labelSide: "pov"
});
}
const slots = [
{ x: 0.41, y: 0.48, role: "partner" },
{ x: 0.12, y: 0.55, role: "support" },
{ x: 0.11, y: 0.19, role: "support" },
{ x: 0.33, y: 0.70, role: "context" },
{ x: 0.05, y: 0.36, role: "context" }
];
const placed = [...positions.values()];
others.forEach((character, index) => {
const contextual = isContextCharacter(character);
const size = Math.round(contextual
? Math.min(108, Math.max(82, 70 + character.weight * 0.42))
: Math.min(154, Math.max(116, 96 + character.weight * 0.58)));
const slot = slots[index] || slots[slots.length - 1];
const desired = {
x: width * slot.x - size / 2,
y: height * slot.y - size / 2
};
const resolved = avoidNodeCollisions(desired, size, placed, width, height);
const position = {
x: resolved.x,
y: resolved.y,
size,
scale: 1,
opacity: contextual ? 0.76 : 0.94,
z: contextual ? 3 : 5,
layoutRole: contextual ? "context" : slot.role,
labelSide: contextual && pov && resolved.x < positions.get(pov.id).x ? "left" : slot.role === "partner" || slot.role === "support" ? "right" : "below"
};
placed.push(position);
positions.set(character.id, position);
});
return positions;
}
function characterPosition(character, index, povId, characters) {
const bounds = dom.visualStage?.getBoundingClientRect(); const bounds = dom.visualStage?.getBoundingClientRect();
const width = Math.max(820, bounds?.width || 1040); const width = Math.max(820, bounds?.width || 1040);
const height = Math.max(500, bounds?.height || 560); const height = Math.max(500, bounds?.height || 560);
const layout = characterLayout({ characters: characters || [character], povCharacterId: povId }, width, height);
if (layout.has(character.id)) return layout.get(character.id);
const povSize = Math.min(210, Math.max(166, width * 0.16)); const povSize = Math.min(210, Math.max(166, width * 0.16));
if (character.id === povId) { if (character.id === povId) {
@ -498,7 +655,7 @@
{ x: 0.72, y: 0.19 } { x: 0.72, y: 0.19 }
]; ];
const anchor = anchors[index % anchors.length]; const anchor = anchors[index % anchors.length];
const size = Math.min(116, Math.max(76, 64 + Math.min(asset.weight, 100) * 0.42)); const size = Math.min(104, Math.max(68, (64 + Math.min(asset.weight, 100) * 0.42) * 0.9));
return { return {
x: Math.round(width * anchor.x - size / 2), x: Math.round(width * anchor.x - size / 2),
y: Math.round(height * anchor.y - size / 2), y: Math.round(height * anchor.y - size / 2),
@ -507,6 +664,72 @@
}; };
} }
function assetLayout(scene, width, height) {
const positions = new Map();
const total = scene.assets.length;
const anchorsByTotal = {
1: [{ x: 0.83, y: 0.48 }],
2: [{ x: 0.83, y: 0.34 }, { x: 0.83, y: 0.62 }],
3: [{ x: 0.82, y: 0.25 }, { x: 0.85, y: 0.50 }, { x: 0.81, y: 0.75 }],
4: [{ x: 0.78, y: 0.16 }, { x: 0.90, y: 0.37 }, { x: 0.86, y: 0.62 }, { x: 0.73, y: 0.82 }],
5: [{ x: 0.76, y: 0.13 }, { x: 0.91, y: 0.30 }, { x: 0.80, y: 0.48 }, { x: 0.92, y: 0.66 }, { x: 0.72, y: 0.84 }]
};
const anchors = anchorsByTotal[Math.min(5, Math.max(1, total))] || anchorsByTotal[5];
const placed = [];
scene.assets.forEach((asset, index) => {
const anchor = anchors[index] || anchors[anchors.length - 1];
const important = index === 0 || asset.weight >= 90;
const size = Math.round(important
? Math.min(104, Math.max(83, (70 + Math.min(asset.weight, 100) * 0.42) * 0.9))
: Math.min(88, Math.max(68, (58 + Math.min(asset.weight, 100) * 0.32) * 0.9)));
const desired = {
x: width * anchor.x - size / 2,
y: height * anchor.y - size / 2
};
const resolved = avoidNodeCollisions(desired, size, placed, width, height);
const position = { x: resolved.x, y: resolved.y, size, scale: 1, z: important ? 4 : 3 };
placed.push(position);
positions.set(asset.id, position);
});
return positions;
}
function avoidNodeCollisions(desired, size, placed, width, height) {
const padding = Math.max(16, size * 0.12);
const candidates = [
desired,
{ x: desired.x, y: desired.y + size * 0.34 },
{ x: desired.x + size * 0.42, y: desired.y },
{ x: desired.x - size * 0.42, y: desired.y },
{ x: desired.x, y: desired.y - size * 0.34 },
{ x: desired.x + size * 0.34, y: desired.y + size * 0.28 },
{ x: desired.x - size * 0.34, y: desired.y + size * 0.28 }
];
let best = clampPosition(candidates[0], size, width, height);
let bestScore = Number.POSITIVE_INFINITY;
candidates.forEach((candidate) => {
const clamped = clampPosition(candidate, size, width, height);
const score = placed.reduce((total, other) => {
const distance = Math.hypot((clamped.x + size / 2) - (other.x + other.size / 2), (clamped.y + size / 2) - (other.y + other.size / 2));
const minDistance = (size + other.size) * 0.45 + padding;
return total + Math.max(0, minDistance - distance) * 12;
}, Math.hypot(clamped.x - desired.x, clamped.y - desired.y));
if (score < bestScore) {
bestScore = score;
best = clamped;
}
});
return best;
}
function clampPosition(position, size, width, height) {
return {
x: Math.round(clamp(position.x, 18, width - size - 18)),
y: Math.round(clamp(position.y, 46, height - size - 58))
};
}
function centrePoint(node, parentRect) { function centrePoint(node, parentRect) {
const rect = node.getBoundingClientRect(); const rect = node.getBoundingClientRect();
return { return {
@ -516,17 +739,187 @@
} }
function connectionPoint(node, parentRect, targetNode) { function connectionPoint(node, parentRect, targetNode) {
const centre = centrePoint(node, parentRect); const image = node.querySelector(".story-character__image, .story-location__image, .story-asset__image") || node;
const rect = node.getBoundingClientRect(); const centre = centrePoint(image, parentRect);
const rect = image.getBoundingClientRect();
const target = targetNode ? centrePoint(targetNode, parentRect) : centre; const target = targetNode ? centrePoint(targetNode, parentRect) : centre;
const radius = Math.min(rect.width, rect.height) * 0.44; const targetImage = targetNode?.querySelector?.(".story-character__image, .story-location__image, .story-asset__image");
const angle = Math.atan2(target.y - centre.y, target.x - centre.x); const targetCentre = targetImage ? centrePoint(targetImage, parentRect) : target;
const radius = Math.min(rect.width, rect.height) * 0.5;
const angle = Math.atan2(targetCentre.y - centre.y, targetCentre.x - centre.x);
return { return {
x: centre.x + Math.cos(angle) * radius, x: centre.x + Math.cos(angle) * radius,
y: centre.y + Math.sin(angle) * radius y: centre.y + Math.sin(angle) * radius
}; };
} }
function routeConnection(start, end, index, obstacles) {
const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.max(1, Math.hypot(dx, dy));
const normal = { x: -dy / length, y: dx / length };
const tangent = { x: dx / length, y: dy / length };
const base = Math.max(34, Math.min(122, length * 0.2));
const direction = index % 2 === 0 ? 1 : -1;
const offsets = [direction * base, -direction * base, direction * base * 1.45, -direction * base * 1.45, 0];
let best = offsets[0];
let bestScore = Number.POSITIVE_INFINITY;
offsets.forEach((offset) => {
const controls = cubicControls(start, end, tangent, normal, offset, length);
const samples = [0.18, 0.33, 0.5, 0.67, 0.82].map((t) => pointOnCubic(start, controls.startControl, controls.endControl, end, t));
const score = samples.reduce((total, point) => total + obstacles.reduce((sum, rect) => sum + pointRectPenalty(point, rect), 0), Math.abs(offset) * 0.04);
if (score < bestScore) {
bestScore = score;
best = offset;
}
});
return cubicControls(start, end, tangent, normal, best, length);
}
function cubicControls(start, end, tangent, normal, offset, length) {
const pull = Math.max(52, Math.min(150, length * 0.34));
return {
startControl: {
x: start.x + tangent.x * pull + normal.x * offset,
y: start.y + tangent.y * pull + normal.y * offset
},
endControl: {
x: end.x - tangent.x * pull + normal.x * offset,
y: end.y - tangent.y * pull + normal.y * offset
},
normal,
offset
};
}
function layoutRects(parentRect) {
return [...dom.visualStage.querySelectorAll(".story-character__image, .story-location__image, .story-asset__image, .story-character__text, .story-location__text, .story-asset__text")]
.map((node) => rectRelativeTo(node, parentRect, node.className.includes("__text") ? 8 : 10));
}
function connectionObstacles(parentRect, source, target) {
const sourceImage = source.querySelector(".story-character__image, .story-location__image, .story-asset__image") || source;
const targetImage = target.querySelector(".story-character__image, .story-location__image, .story-asset__image") || target;
return [...dom.visualStage.querySelectorAll(".story-character__image, .story-location__image, .story-asset__image, .story-character__text, .story-location__text, .story-asset__text")]
.filter((node) => node !== sourceImage && node !== targetImage)
.map((node) => rectRelativeTo(node, parentRect, node.className.includes("__text") ? 8 : 12));
}
function rectRelativeTo(node, parentRect, padding) {
const rect = node.getBoundingClientRect();
return {
left: rect.left - parentRect.left - padding,
right: rect.right - parentRect.left + padding,
top: rect.top - parentRect.top - padding,
bottom: rect.bottom - parentRect.top + padding
};
}
function pointOnQuadratic(start, control, end, t) {
const inverse = 1 - t;
return {
x: inverse * inverse * start.x + 2 * inverse * t * control.x + t * t * end.x,
y: inverse * inverse * start.y + 2 * inverse * t * control.y + t * t * end.y
};
}
function pointOnCubic(start, startControl, endControl, end, t) {
const inverse = 1 - t;
return {
x: inverse ** 3 * start.x + 3 * inverse ** 2 * t * startControl.x + 3 * inverse * t ** 2 * endControl.x + t ** 3 * end.x,
y: inverse ** 3 * start.y + 3 * inverse ** 2 * t * startControl.y + 3 * inverse * t ** 2 * endControl.y + t ** 3 * end.y
};
}
function relationshipLabelPosition(start, route, end, occupiedRects) {
const sign = Math.sign(route.offset || 1);
const candidates = [0.42, 0.5, 0.58, 0.34, 0.66].flatMap((t) => {
const point = pointOnCubic(start, route.startControl, route.endControl, end, t);
return [34, 54, 76, -42, -64].map((distance) => ({
x: point.x + route.normal.x * sign * distance,
y: point.y + route.normal.y * sign * distance - 5
}));
});
const best = candidates.reduce((current, candidate) => {
const score = occupiedRects.reduce((sum, rect) => sum + labelRectPenalty(candidate, rect), 0);
return score < current.score ? { point: candidate, score } : current;
}, { point: candidates[0], score: Number.POSITIVE_INFINITY });
return best.score >= 1000 ? null : best.point;
}
function labelRectPenalty(point, rect) {
const labelRect = {
left: point.x - 70,
right: point.x + 70,
top: point.y - 18,
bottom: point.y + 8
};
if (rectsIntersect(labelRect, rect)) return 1000;
const dx = Math.max(rect.left - labelRect.right, 0, labelRect.left - rect.right);
const dy = Math.max(rect.top - labelRect.bottom, 0, labelRect.top - rect.bottom);
return Math.max(0, 30 - Math.hypot(dx, dy));
}
function rectsIntersect(a, b) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
function fitConnectionLabelBackground(group) {
const label = group.querySelector(".story-exp-connection-label");
if (!label || group.querySelector("rect")) return;
const box = label.getBBox();
const background = document.createElementNS("http://www.w3.org/2000/svg", "rect");
background.setAttribute("class", "story-exp-connection-label-bg");
background.setAttribute("x", String(box.x - 8));
background.setAttribute("y", String(box.y - 4));
background.setAttribute("width", String(box.width + 16));
background.setAttribute("height", String(box.height + 8));
background.setAttribute("rx", "7");
group.insertBefore(background, label);
}
function pointRectPenalty(point, rect) {
if (point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom) return 1000;
const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
const distance = Math.hypot(dx, dy);
return Math.max(0, 48 - distance);
}
function conciseCharacterLabel(character, isPov) {
if (isPov) return "POV";
const value = `${character.role} ${character.relevance}`.toLowerCase();
if (value.includes("suspect") || value.includes("threat") || value.includes("opposing")) return "Suspect";
if (value.includes("witness") || value.includes("neighbour")) return "Witness";
if (value.includes("ally") || value.includes("protected")) return "Ally";
if (value.includes("absent") || value.includes("remembered") || value.includes("voice") || value.includes("unseen")) return "Absent";
if (value.includes("letter") || value.includes("evidence") || value.includes("target")) return "Evidence";
return compactPhrase(character.role || character.relevance, 18);
}
function conciseLocationLabel(location) {
return compactPhrase(location.label, 30);
}
function conciseAssetLabel(asset) {
return compactPhrase(asset.label, 24);
}
function compactPhrase(value, limit) {
const textValue = String(value || "").replace(/\s+/g, " ").trim();
return textValue.length > limit ? `${textValue.slice(0, limit - 1).trim()}...` : textValue;
}
function isContextCharacter(character) {
const value = `${character.role} ${character.relevance}`.toLowerCase();
return character.weight < 65 || value.includes("absent") || value.includes("remembered") || value.includes("unseen") || value.includes("voice");
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function relationshipTone(relationship) { function relationshipTone(relationship) {
const stateText = `${relationship.label} ${relationship.state}`.toLowerCase(); const stateText = `${relationship.label} ${relationship.state}`.toLowerCase();
if (stateText.includes("conflict") || stateText.includes("threat") || stateText.includes("obstruction") || stateText.includes("suspect")) return "conflict"; if (stateText.includes("conflict") || stateText.includes("threat") || stateText.includes("obstruction") || stateText.includes("suspect")) return "conflict";
@ -616,12 +1009,24 @@
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
const current = loadSceneState(state.index); const current = loadSceneState(state.index);
reconcileCharacters(current); const layout = sceneLayout(current);
reconcileCharacters(current, current, layout);
reconcileLocation(current, current); reconcileLocation(current, current);
reconcileAssets(current); reconcileAssets(current, current, layout);
scheduleConnectionDraw(); scheduleConnectionDraw();
}); });
if (window.ResizeObserver && dom.visualStage) {
const observer = new ResizeObserver(() => {
const current = loadSceneState(state.index);
const layout = sceneLayout(current);
reconcileCharacters(current, current, layout);
reconcileAssets(current, current, layout);
scheduleConnectionDraw();
});
observer.observe(dom.visualStage);
}
transitionToNextState(0); transitionToNextState(0);
startTimer(); startTimer();
})(); })();