Compare commits
3 Commits
07a5477034
...
1f984c8e1e
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f984c8e1e | |||
| 35208591fd | |||
| f0b444e0ee |
@ -33,6 +33,7 @@ var tests = new (string Name, Action Test)[]
|
||||
("Illustration storage codes are normalised safely", IllustrationStorageCodesAreNormalisedSafely),
|
||||
("Illustration starter batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts),
|
||||
("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 skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes),
|
||||
("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel),
|
||||
@ -336,9 +337,14 @@ static void IllustrationPromptBuilderSeparatesSpecAndPrompt()
|
||||
var result = builder.Build(spec);
|
||||
|
||||
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("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("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.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.");
|
||||
@ -427,6 +433,23 @@ static void IllustrationStarterGenerationPlanSkipsSuccessfulItems()
|
||||
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()
|
||||
{
|
||||
var result = new IllustrationBulkRetryResult(12, 4, 3);
|
||||
|
||||
@ -16,7 +16,7 @@ public interface IIllustrationPromptBuilder
|
||||
|
||||
public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
|
||||
{
|
||||
public const string CurrentTemplateVersion = "21D.1";
|
||||
public const string CurrentTemplateVersion = "21D.2";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@ -56,12 +56,17 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
|
||||
|
||||
Requirements:
|
||||
- representative visual identifier only, not a canonical depiction of any manuscript
|
||||
- editorial illustration, artistic sketch or painted concept art, stylised realism
|
||||
- sophisticated and calm, suitable for a dark navy interface
|
||||
- calm cinematic lighting and clear silhouette
|
||||
- modern cinematic digital illustration with premium narrative game concept art quality
|
||||
- vivid natural colour, contemporary polished rendering, realistic anatomy and believable proportions
|
||||
- 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 anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim
|
||||
- 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
|
||||
""";
|
||||
|
||||
@ -71,9 +76,9 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
|
||||
private static string CategoryRules(string category)
|
||||
=> 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.Location => "One clear focal environment; usable in circular and landscape crops; no prominent people; no text or readable signage.",
|
||||
IllustrationLibraryCategories.Asset => "Square image; one dominant object; recognisable silhouette; clean subdued background; entire object visible where practical.",
|
||||
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; 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 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
|
||||
};
|
||||
|
||||
@ -99,34 +104,34 @@ public static class IllustrationStarterBatchDefinition
|
||||
{
|
||||
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-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-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-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-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.", "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, 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 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-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-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-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.", "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-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"]),
|
||||
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-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-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-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, clean readable light", "intimate, investigative", ["fresh green", "warm paper", "clear blue"]),
|
||||
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-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"]),
|
||||
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-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-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 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-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-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-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-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-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", ["fresh green", "paper tan", "red accent"])
|
||||
];
|
||||
|
||||
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(
|
||||
IIllustrationLibraryRepository repository,
|
||||
IIllustrationPromptBuilder promptBuilder,
|
||||
IIllustrationImageProvider provider) : IIllustrationLibraryService
|
||||
IIllustrationImageProvider provider,
|
||||
IUploadStorageService uploadStorage) : IIllustrationLibraryService
|
||||
{
|
||||
private const int MaxGenerationAttempts = 3;
|
||||
|
||||
@ -894,34 +900,109 @@ public sealed class IllustrationLibraryService(
|
||||
var items = await repository.ListAsync(new IllustrationLibraryFilter());
|
||||
var lookup = items
|
||||
.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)
|
||||
.ThenByDescending(item => item.UpdatedUtc)
|
||||
.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)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scene.Location.LibraryCode) && lookup.TryGetValue(scene.Location.LibraryCode, out var locationPath))
|
||||
{
|
||||
scene.Location.ImagePath = locationPath;
|
||||
}
|
||||
var locationFallback = scene.Location.ImagePath;
|
||||
var locationResolution = ResolvePrototypeImage(scene.Location.Id, scene.Location.LibraryCode, locationFallback, lookup);
|
||||
scene.Location.ImageResolution = locationResolution;
|
||||
scene.Location.ImagePath = locationResolution.FinalImageUrl ?? locationFallback;
|
||||
|
||||
foreach (var character in scene.Characters)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(character.LibraryCode) && lookup.TryGetValue(character.LibraryCode, out var characterPath))
|
||||
{
|
||||
character.ImagePath = characterPath;
|
||||
}
|
||||
var fallback = character.ImagePath;
|
||||
var resolution = ResolvePrototypeImage(character.Id, character.LibraryCode, fallback, lookup);
|
||||
character.ImageResolution = resolution;
|
||||
character.ImagePath = resolution.FinalImageUrl ?? fallback;
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
@ -42,6 +42,7 @@ public sealed class StoryIntelligenceExperienceCharacterState
|
||||
public string Relevance { get; init; } = string.Empty;
|
||||
public int Weight { get; init; }
|
||||
public string ImagePath { get; set; } = string.Empty;
|
||||
public StoryIntelligenceExperienceImageResolution ImageResolution { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceExperienceLocationState
|
||||
@ -51,6 +52,7 @@ public sealed class StoryIntelligenceExperienceLocationState
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Label { get; init; } = string.Empty;
|
||||
public string ImagePath { get; set; } = string.Empty;
|
||||
public StoryIntelligenceExperienceImageResolution ImageResolution { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceExperienceAssetState
|
||||
@ -61,6 +63,18 @@ public sealed class StoryIntelligenceExperienceAssetState
|
||||
public string Label { get; init; } = string.Empty;
|
||||
public int Weight { get; init; }
|
||||
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
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
<body>
|
||||
<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">
|
||||
<div class="story-exp-brand">
|
||||
<strong>PlotDirector</strong>
|
||||
|
||||
@ -496,33 +496,36 @@ input:focus-visible {
|
||||
|
||||
.story-exp-stage {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) 158px;
|
||||
gap: 10px;
|
||||
grid-template-rows: auto minmax(0, 1fr) 154px;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.story-exp-stage-copy {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(240px, 0.72fr);
|
||||
align-items: end;
|
||||
gap: 22px;
|
||||
min-height: 62px;
|
||||
grid-template-columns: minmax(260px, 0.64fr) 1fr;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.story-exp-stage-copy h1 {
|
||||
grid-column: 1;
|
||||
margin: 0;
|
||||
font-size: clamp(1.55rem, 2.35vw, 2.75rem);
|
||||
line-height: 1.05;
|
||||
overflow: hidden;
|
||||
font-size: clamp(1rem, 1.2vw, 1.34rem);
|
||||
line-height: 1.12;
|
||||
letter-spacing: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.story-exp-stage-copy p:last-child {
|
||||
grid-column: 2;
|
||||
margin: 0;
|
||||
color: var(--story-soft);
|
||||
font-size: clamp(0.86rem, 1vw, 1rem);
|
||||
line-height: 1.38;
|
||||
font-size: clamp(0.78rem, 0.86vw, 0.92rem);
|
||||
line-height: 1.28;
|
||||
}
|
||||
|
||||
.story-exp-visual {
|
||||
@ -565,51 +568,57 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.story-exp-connection {
|
||||
stroke: rgba(215, 168, 93, 0.5);
|
||||
stroke: rgba(215, 168, 93, 0.48);
|
||||
stroke-linecap: round;
|
||||
stroke-width: 1.8;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
fill: none;
|
||||
filter: drop-shadow(0 0 7px rgba(215, 168, 93, 0.22));
|
||||
animation: drawLine 950ms cubic-bezier(.16, .84, .28, 1) forwards;
|
||||
filter: drop-shadow(0 0 5px rgba(215, 168, 93, 0.18));
|
||||
animation: drawLine 760ms cubic-bezier(.16, .84, .28, 1) forwards;
|
||||
}
|
||||
|
||||
.story-exp-connection-group--presence .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--knowledge .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 {
|
||||
stroke: rgba(121, 210, 166, 0.64);
|
||||
stroke: rgba(121, 210, 166, 0.58);
|
||||
}
|
||||
|
||||
.story-exp-connection-group--conflict .story-exp-connection {
|
||||
stroke: rgba(227, 140, 168, 0.76);
|
||||
stroke-dasharray: 0.18 0.1;
|
||||
stroke: rgba(227, 140, 168, 0.7);
|
||||
stroke-dasharray: 0.2 0.12;
|
||||
}
|
||||
|
||||
.story-exp-connection-group--uncertain .story-exp-connection {
|
||||
stroke: rgba(182, 162, 255, 0.56);
|
||||
stroke-dasharray: 0.08 0.09;
|
||||
stroke: rgba(182, 162, 255, 0.48);
|
||||
stroke-dasharray: 0.12 0.11;
|
||||
}
|
||||
|
||||
.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 {
|
||||
paint-order: stroke;
|
||||
stroke: rgba(4, 10, 20, 0.86);
|
||||
stroke-width: 5px;
|
||||
stroke: rgba(4, 10, 20, 0.68);
|
||||
stroke-width: 3px;
|
||||
fill: var(--story-soft);
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: lowercase;
|
||||
@ -656,17 +665,22 @@ input:focus-visible {
|
||||
|
||||
.story-character {
|
||||
position: absolute;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
display: block;
|
||||
width: var(--node-size, 150px);
|
||||
height: var(--node-size, 150px);
|
||||
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
|
||||
opacity: var(--opacity, 1);
|
||||
z-index: var(--z, 3);
|
||||
pointer-events: auto;
|
||||
transition: transform 1050ms cubic-bezier(.16, .84, .28, 1), opacity 780ms ease;
|
||||
}
|
||||
|
||||
.story-character.is-pov {
|
||||
z-index: 4;
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.story-character.is-context .story-character__image {
|
||||
filter: saturate(0.82);
|
||||
}
|
||||
|
||||
.story-character__image,
|
||||
@ -699,21 +713,84 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.story-character__text {
|
||||
position: absolute;
|
||||
top: calc(var(--node-size, 150px) + 9px);
|
||||
left: 50%;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
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;
|
||||
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 {
|
||||
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 {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
color: var(--story-muted);
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.68rem;
|
||||
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 {
|
||||
@ -723,6 +800,7 @@ input:focus-visible {
|
||||
gap: 10px;
|
||||
width: var(--node-size, 190px);
|
||||
transform: translate(var(--x, 0), var(--y, 0));
|
||||
z-index: 4;
|
||||
pointer-events: auto;
|
||||
transition: opacity 560ms ease, transform 850ms cubic-bezier(.16, .84, .28, 1);
|
||||
}
|
||||
@ -745,15 +823,37 @@ input:focus-visible {
|
||||
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 {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
font-size: clamp(1.12rem, 1.55vw, 1.55rem);
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.story-location p {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
color: var(--story-muted);
|
||||
font-size: 0.86rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.story-exp-assets {
|
||||
@ -766,37 +866,54 @@ input:focus-visible {
|
||||
position: absolute;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 7px;
|
||||
width: max(var(--node-size, 86px), 112px);
|
||||
gap: 6px;
|
||||
width: max(var(--node-size, 78px), 104px);
|
||||
opacity: var(--opacity, 1);
|
||||
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
|
||||
z-index: var(--z, 3);
|
||||
pointer-events: auto;
|
||||
transition: transform 860ms cubic-bezier(.16, .84, .28, 1), opacity 620ms ease;
|
||||
}
|
||||
|
||||
.story-asset__image {
|
||||
width: var(--node-size, 86px);
|
||||
height: var(--node-size, 86px);
|
||||
width: var(--node-size, 78px);
|
||||
height: var(--node-size, 78px);
|
||||
border-radius: 50%;
|
||||
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 {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
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;
|
||||
}
|
||||
|
||||
.story-asset strong,
|
||||
.story-asset span {
|
||||
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 {
|
||||
color: var(--story-muted);
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.story-exp-ribbon-wrap {
|
||||
|
||||
@ -8,11 +8,14 @@
|
||||
if (!scenes.length) return;
|
||||
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const failedImageUrls = new Set();
|
||||
const preloadedImageUrls = new Set();
|
||||
const state = {
|
||||
index: 0,
|
||||
playing: true,
|
||||
intervalMs: 6200,
|
||||
timer: null
|
||||
timer: null,
|
||||
drawRequest: null
|
||||
};
|
||||
const analysisStages = [
|
||||
{ key: "reading", label: "Reading" },
|
||||
@ -65,13 +68,17 @@
|
||||
const previous = loadSceneState(state.index);
|
||||
state.index = Math.max(0, Math.min(scenes.length - 1, nextIndex));
|
||||
const current = loadSceneState(state.index);
|
||||
preloadSceneImages(current);
|
||||
preloadSceneImages(loadSceneState(Math.min(scenes.length - 1, state.index + 1)));
|
||||
logImageDiagnostics(current);
|
||||
|
||||
updateProgress(current);
|
||||
updateAnalysisStages(current);
|
||||
updateSceneCopy(current);
|
||||
reconcileCharacters(current, previous);
|
||||
const layout = sceneLayout(current);
|
||||
reconcileCharacters(current, previous, layout);
|
||||
reconcileLocation(current, previous);
|
||||
reconcileAssets(current, previous);
|
||||
reconcileAssets(current, previous, layout);
|
||||
reconcileRelationships(current);
|
||||
updateKnowledgeThreads(current);
|
||||
updateSceneRibbon(current);
|
||||
@ -124,11 +131,74 @@
|
||||
}
|
||||
|
||||
function updateSceneCopy(scene) {
|
||||
text(dom.sceneTitle, scene.title);
|
||||
text(dom.sceneTitle, `Scene ${scene.sceneNumber} • ${scene.title}`);
|
||||
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));
|
||||
scene.characters.forEach((character, index) => {
|
||||
let node = dom.characters.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
|
||||
@ -137,17 +207,23 @@
|
||||
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-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("--x", `${position.x}px`);
|
||||
node.style.setProperty("--y", `${position.y}px`);
|
||||
node.style.setProperty("--scale", String(position.scale));
|
||||
node.style.setProperty("--opacity", String(position.opacity));
|
||||
node.querySelector("img").src = character.imagePath;
|
||||
node.querySelector("img").alt = `${character.name} prototype portrait`;
|
||||
node.style.setProperty("--z", String(position.z));
|
||||
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-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) => {
|
||||
@ -183,15 +259,16 @@
|
||||
node.style.setProperty("--node-size", `${position.size}px`);
|
||||
node.style.setProperty("--x", `${position.x}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("p"), scene.location.label);
|
||||
text(node.querySelector("p"), conciseLocationLabel(scene.location));
|
||||
node.title = `${scene.location.name}: ${scene.location.label}`;
|
||||
dom.location.append(node);
|
||||
scheduleConnectionDraw();
|
||||
}, changed && !reducedMotion ? 260 : 0);
|
||||
}
|
||||
|
||||
function reconcileAssets(scene) {
|
||||
function reconcileAssets(scene, previous, layout) {
|
||||
const activeIds = new Set(scene.assets.map((asset) => asset.id));
|
||||
scene.assets.forEach((asset, index) => {
|
||||
let node = dom.assets.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
|
||||
@ -200,16 +277,17 @@
|
||||
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("--x", `${position.x}px`);
|
||||
node.style.setProperty("--y", `${position.y}px`);
|
||||
node.style.setProperty("--scale", String(position.scale));
|
||||
node.style.setProperty("--z", String(position.z));
|
||||
node.style.setProperty("--opacity", "1");
|
||||
node.querySelector("img").src = asset.imagePath;
|
||||
node.querySelector("img").alt = `${asset.name} prototype asset`;
|
||||
setEntityImage(node.querySelector("img"), asset.imagePath, asset.imageResolution, `${asset.name} prototype asset`);
|
||||
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) => {
|
||||
@ -325,7 +403,14 @@
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -333,6 +418,7 @@
|
||||
const stageRect = dom.visualStage.getBoundingClientRect();
|
||||
dom.connections.innerHTML = "";
|
||||
const drawn = [];
|
||||
const occupiedRects = layoutRects(stageRect);
|
||||
|
||||
scene.relationships.forEach((relationship, index) => {
|
||||
const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`);
|
||||
@ -341,67 +427,69 @@
|
||||
|
||||
const start = connectionPoint(source, stageRect, target);
|
||||
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 location = dom.visualStage.querySelector("[data-location-id]");
|
||||
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
|
||||
.filter((character) => character.id !== scene.povCharacterId && character.weight >= 65)
|
||||
.slice(0, 2)
|
||||
.slice(0, 1)
|
||||
.forEach((character) => {
|
||||
const node = dom.visualStage.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
|
||||
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) => {
|
||||
const assetNode = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
|
||||
if (!assetNode || !location) return;
|
||||
drawn.push(drawConnection(connectionPoint(location, stageRect, assetNode), connectionPoint(assetNode, stageRect, location), "evidence", asset.weight, index === 0 ? assetConnectionLabel(asset) : ""));
|
||||
if (pov && index < 2) {
|
||||
drawn.push(drawConnection(connectionPoint(pov, stageRect, assetNode), connectionPoint(assetNode, stageRect, pov), "knowledge", Math.max(45, asset.weight - 18), ""));
|
||||
}
|
||||
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 (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");
|
||||
group.setAttribute("class", `story-exp-connection-group story-exp-connection-group--${tone}`);
|
||||
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.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}`;
|
||||
const route = routeConnection(start, end, index, obstacles);
|
||||
const d = `M ${start.x} ${start.y} C ${route.startControl.x} ${route.startControl.y}, ${route.endControl.x} ${route.endControl.y}, ${end.x} ${end.y}`;
|
||||
path.setAttribute("d", d);
|
||||
path.setAttribute("pathLength", "1");
|
||||
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);
|
||||
|
||||
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");
|
||||
labelNode.setAttribute("class", "story-exp-connection-label");
|
||||
labelNode.setAttribute("x", String((start.x + end.x) / 2));
|
||||
labelNode.setAttribute("y", String((start.y + end.y) / 2 - 8));
|
||||
labelNode.setAttribute("x", String(labelPosition.x));
|
||||
labelNode.setAttribute("y", String(labelPosition.y));
|
||||
labelNode.setAttribute("text-anchor", "middle");
|
||||
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);
|
||||
@ -434,10 +522,79 @@
|
||||
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 width = Math.max(820, bounds?.width || 1040);
|
||||
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));
|
||||
|
||||
if (character.id === povId) {
|
||||
@ -498,7 +655,7 @@
|
||||
{ x: 0.72, y: 0.19 }
|
||||
];
|
||||
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 {
|
||||
x: Math.round(width * anchor.x - 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) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
@ -516,17 +739,187 @@
|
||||
}
|
||||
|
||||
function connectionPoint(node, parentRect, targetNode) {
|
||||
const centre = centrePoint(node, parentRect);
|
||||
const rect = node.getBoundingClientRect();
|
||||
const image = node.querySelector(".story-character__image, .story-location__image, .story-asset__image") || node;
|
||||
const centre = centrePoint(image, parentRect);
|
||||
const rect = image.getBoundingClientRect();
|
||||
const target = targetNode ? centrePoint(targetNode, parentRect) : centre;
|
||||
const radius = Math.min(rect.width, rect.height) * 0.44;
|
||||
const angle = Math.atan2(target.y - centre.y, target.x - centre.x);
|
||||
const targetImage = targetNode?.querySelector?.(".story-character__image, .story-location__image, .story-asset__image");
|
||||
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 {
|
||||
x: centre.x + Math.cos(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) {
|
||||
const stateText = `${relationship.label} ${relationship.state}`.toLowerCase();
|
||||
if (stateText.includes("conflict") || stateText.includes("threat") || stateText.includes("obstruction") || stateText.includes("suspect")) return "conflict";
|
||||
@ -616,12 +1009,24 @@
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
const current = loadSceneState(state.index);
|
||||
reconcileCharacters(current);
|
||||
const layout = sceneLayout(current);
|
||||
reconcileCharacters(current, current, layout);
|
||||
reconcileLocation(current, current);
|
||||
reconcileAssets(current);
|
||||
reconcileAssets(current, current, layout);
|
||||
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);
|
||||
startTimer();
|
||||
})();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user