The top bar becomes a steel gantry. Its centre window is a mask cut-out the 3D exit sign docks into; between signs the window shows the dash read-out. The drive controls move into the right bay, and the reading viewport is clipped at the beam, so the empty band under the bar is gone.
359 lines
15 KiB
Vue
359 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
|
|
import ContactDestination from './ContactDestination.vue';
|
|
import DriveControls from './DriveControls.vue';
|
|
import ExperienceTimeline from './ExperienceTimeline.vue';
|
|
import HeroJourney from './HeroJourney.vue';
|
|
import InfrastructureDiagram from './InfrastructureDiagram.vue';
|
|
import JourneyHud from './JourneyHud.vue';
|
|
import PortfolioSection from './PortfolioSection.vue';
|
|
import ProjectCard from './ProjectCard.vue';
|
|
import ProjectCaseStudy from './ProjectCaseStudy.vue';
|
|
import SceneCanvas from './SceneCanvas.vue';
|
|
import SiteNavigation from './SiteNavigation.vue';
|
|
import ToolkitGrid from './ToolkitGrid.vue';
|
|
import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality';
|
|
import { useAudioEngine } from '@/composables/useAudioEngine';
|
|
import { useAudioIntent } from '@/composables/useAudioIntent';
|
|
import { useJourneySections } from '@/composables/useJourneySections';
|
|
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
|
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
|
import { useRouteMemory } from '@/composables/useRouteMemory';
|
|
import { assetUrl } from '@/content/asset-url';
|
|
import { signRouteFor } from '@/journey/sign-routes';
|
|
import type { PortfolioContent, PublicAssetManifest, SectionId, SectionLayout } from '@/content/types';
|
|
import type { SceneHostState } from '@/composables/useSceneController';
|
|
import type { MotionPolicy } from '@/journey/motion-policy';
|
|
import type { AudioSourceVariant, SoundtrackSources } from '@/audio/types';
|
|
|
|
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
|
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
|
|
|
const props = defineProps<{
|
|
content: Readonly<PortfolioContent>;
|
|
manifest: Readonly<PublicAssetManifest>;
|
|
}>();
|
|
|
|
const canonicalSectionIds = [
|
|
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
|
|
] as const satisfies readonly SectionId[];
|
|
|
|
const sectionLayouts = {
|
|
start: 'hero',
|
|
profile: 'right-rail',
|
|
experience: 'left-timeline',
|
|
infrastructure: 'full-diagram',
|
|
'selected-builds': 'project-stage',
|
|
'additional-work': 'compact-rail',
|
|
toolkit: 'toolkit-field',
|
|
contact: 'destination',
|
|
} as const satisfies Readonly<Record<SectionId, SectionLayout>>;
|
|
|
|
const navigationById = computed(() => new Map(props.content.navigation.map((item) => [item.id, item])));
|
|
|
|
const sectionLabel = (id: SectionId): string => {
|
|
const item = navigationById.value.get(id);
|
|
if (item === undefined) {
|
|
throw new Error(`Missing canonical navigation label: ${id}`);
|
|
}
|
|
return item.label;
|
|
};
|
|
|
|
/** One wayfinding vocabulary: chapter plates reuse the roadside sign markers. */
|
|
const exitFor = (id: SectionId): string => signRouteFor(id).marker;
|
|
const nextExitFor = (id: SectionId): string | undefined => {
|
|
const next = canonicalSectionIds[canonicalSectionIds.indexOf(id) + 1];
|
|
return next === undefined ? undefined : exitFor(next);
|
|
};
|
|
const nextLabelFor = (id: SectionId): string | undefined => {
|
|
const index = canonicalSectionIds.indexOf(id);
|
|
const next = canonicalSectionIds[index + 1];
|
|
return next === undefined ? undefined : sectionLabel(next);
|
|
};
|
|
|
|
const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id, label: sectionLabel(id) })));
|
|
const motion = useMotionPreferences();
|
|
const navigation = useKeyboardNavigation(canonicalSectionIds);
|
|
const journeyBinding = useJourneySections(
|
|
canonicalSectionIds,
|
|
navigation.state,
|
|
computed(() => motion.policy.value.allowSceneRequest),
|
|
);
|
|
const journey = journeyBinding.snapshot;
|
|
const route = useRouteMemory(canonicalSectionIds, () => journey.value.activeSectionId);
|
|
const resume = computed(() => {
|
|
const id = route.resumeFrom.value;
|
|
return id === null ? null : { id, exit: exitFor(id), label: sectionLabel(id) };
|
|
});
|
|
/** Decorative trip log for the destination (CSS generated content). */
|
|
const tripLog = computed(() => {
|
|
const minutes = route.tripMinutes.value;
|
|
if (minutes === null) return undefined;
|
|
const exits = route.visited.value.size;
|
|
return `${exits} OF ${canonicalSectionIds.length} EXITS \u00b7 ${minutes} MIN ON THE ROAD`;
|
|
});
|
|
const audioIntent = useAudioIntent({
|
|
canonicalIds: canonicalSectionIds,
|
|
journey: journeyBinding,
|
|
navigation: navigation.state,
|
|
});
|
|
const variants = (
|
|
sources: { readonly opusAssetId: string; readonly mp3AssetId: string },
|
|
): readonly AudioSourceVariant[] => [
|
|
{ url: assetUrl(props.manifest, sources.opusAssetId), mediaType: 'audio/ogg' },
|
|
{ url: assetUrl(props.manifest, sources.mp3AssetId), mediaType: 'audio/mpeg' },
|
|
];
|
|
const soundtrackSources = computed<SoundtrackSources | null>(() => {
|
|
const soundtrack = props.content.soundtrack;
|
|
if (soundtrack === undefined) return null;
|
|
return {
|
|
intro: variants(soundtrack.intro),
|
|
main: variants(soundtrack.main),
|
|
afterglow: variants(soundtrack.afterglow),
|
|
driveLift: variants(soundtrack.driveLift),
|
|
};
|
|
});
|
|
const audio = useAudioEngine({
|
|
chapter: audioIntent.chapter,
|
|
drive: audioIntent.drive,
|
|
soundtrack: soundtrackSources,
|
|
volume: motion.volume,
|
|
setVolume: motion.setVolume,
|
|
});
|
|
const sceneState = shallowRef<SceneHostState>('idle');
|
|
const pageLifecycle = shallowRef<'active' | 'frozen' | 'disposed'>('active');
|
|
const controlsReady = shallowRef(false);
|
|
const adaptive = useAdaptiveQuality({
|
|
maximum: computed(() => motion.policy.value.maximumTier),
|
|
sceneState,
|
|
pageLifecycle,
|
|
});
|
|
const requestedPerformanceTier = TEST_BUILD
|
|
? new URL(window.location.href).searchParams.get('__portfolio_tier')
|
|
: null;
|
|
const pinnedPerformanceTier = requestedPerformanceTier === 'high' || requestedPerformanceTier === 'low'
|
|
? requestedPerformanceTier
|
|
: null;
|
|
const actualQuality = computed(() => pinnedPerformanceTier ?? adaptive.snapshot.value.actual);
|
|
const activeSectionLabel = computed(() => sectionLabel(journey.value.activeSectionId));
|
|
const serverDefaultPolicy: MotionPolicy = {
|
|
allowSceneRequest: false,
|
|
allowAutomaticVideo: false,
|
|
maximumTier: 'high',
|
|
reason: 'full',
|
|
};
|
|
const displayedPolicy = computed(() => controlsReady.value ? motion.policy.value : serverDefaultPolicy);
|
|
const displayedSceneState = computed(() => controlsReady.value ? sceneState.value : 'running');
|
|
let removePageListeners = (): void => {};
|
|
|
|
const sampleSceneFrame = (start: number, end: number): void => {
|
|
if (pinnedPerformanceTier === null) adaptive.sampleFrame(start, end);
|
|
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordSceneFrame(start, end);
|
|
};
|
|
|
|
const updateSceneState = (state: SceneHostState): void => {
|
|
sceneState.value = state;
|
|
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordSceneState(state);
|
|
};
|
|
|
|
watch(
|
|
() => journey.value.activeSectionId,
|
|
(id) => navigation.notePassiveSection(id),
|
|
);
|
|
|
|
// Light the current chapter's plate once per arrival (attribute only, no per-frame work).
|
|
watch(
|
|
() => [journey.value.activeSectionId, controlsReady.value] as const,
|
|
([id, ready]) => {
|
|
if (!ready) return;
|
|
for (const section of document.querySelectorAll<HTMLElement>('#portfolio-content > section[id]')) {
|
|
section.toggleAttribute('data-current', section.id === id);
|
|
}
|
|
},
|
|
{ flush: 'post' },
|
|
);
|
|
|
|
watch(
|
|
() => motion.policy.value.maximumTier,
|
|
(tier, previous) => {
|
|
if (tier !== previous) {
|
|
adaptive.setMaximum(tier, controlsReady.value ? 'resize' : 'manual');
|
|
}
|
|
},
|
|
{ flush: 'sync' },
|
|
);
|
|
|
|
const setQuality = (tier: import('@/journey/motion-policy').QualityTier): void => {
|
|
motion.setMaximumQuality(tier);
|
|
adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
|
|
};
|
|
|
|
const enableMotion = (): void => {
|
|
const tier = motion.maximumQuality.value === 'static' ? 'high' : motion.maximumQuality.value;
|
|
motion.setMaximumQuality(tier);
|
|
motion.setPaused(false);
|
|
adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
|
|
};
|
|
|
|
const resumeMotion = (): void => {
|
|
motion.setPaused(false);
|
|
adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
|
|
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('resume');
|
|
};
|
|
|
|
const pauseMotion = (): void => {
|
|
motion.setPaused(true);
|
|
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('pause');
|
|
};
|
|
|
|
const startDrive = (): void => {
|
|
audio.startFromGesture();
|
|
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('start');
|
|
};
|
|
|
|
const toggleAudioMuted = (): void => {
|
|
const action = audio.muted.value ? 'unmute' : 'mute';
|
|
audio.toggleMuted();
|
|
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation(action);
|
|
};
|
|
|
|
const updateAudioVolume = (value: number): void => {
|
|
audio.setVolume(value);
|
|
if (TEST_BUILD && value === 0.35) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('volume');
|
|
};
|
|
|
|
onMounted(() => {
|
|
controlsReady.value = true;
|
|
const onPageHide = (event: PageTransitionEvent): void => {
|
|
pageLifecycle.value = event.persisted ? 'frozen' : 'disposed';
|
|
};
|
|
const onPageShow = (): void => { pageLifecycle.value = 'active'; };
|
|
window.addEventListener('pagehide', onPageHide);
|
|
window.addEventListener('pageshow', onPageShow);
|
|
removePageListeners = () => {
|
|
window.removeEventListener('pagehide', onPageHide);
|
|
window.removeEventListener('pageshow', onPageShow);
|
|
};
|
|
});
|
|
|
|
onUnmounted(() => removePageListeners());
|
|
</script>
|
|
|
|
<template>
|
|
<a class="skip-link" href="#portfolio-content">Skip to portfolio content</a>
|
|
<SceneCanvas
|
|
:journey="journey"
|
|
:policy="motion.policy.value"
|
|
:quality="actualQuality"
|
|
:scene-description="content.sceneDescription"
|
|
:sample-frame="sampleSceneFrame"
|
|
@state-change="updateSceneState"
|
|
/>
|
|
<JourneyHud
|
|
:journey="journey"
|
|
:section-label="activeSectionLabel"
|
|
:exit="exitFor(journey.activeSectionId)"
|
|
:next-label="nextLabelFor(journey.activeSectionId)"
|
|
:next-exit="nextExitFor(journey.activeSectionId)"
|
|
/>
|
|
<header class="site-header">
|
|
<SiteNavigation
|
|
:items="canonicalNavigation"
|
|
:journey="journey"
|
|
:visited="route.visited.value"
|
|
:allow-motion="motion.policy.value.allowSceneRequest"
|
|
:on-section-link-click="navigation.onSectionLinkClick"
|
|
/>
|
|
</header>
|
|
|
|
<div class="reading-stage">
|
|
<div class="reading-viewport">
|
|
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
|
|
<HeroJourney :identity="content.identity" :resume="resume" @resume="navigation.onSectionLinkClick" :allow-motion="motion.policy.value.allowSceneRequest && pageLifecycle === 'active'" @explore="navigation.onSectionLinkClick" @contact="navigation.onSectionLinkClick" />
|
|
|
|
<PortfolioSection id="profile" :exit="exitFor('profile')" :next-label="nextLabelFor('profile')" :next-exit="nextExitFor('profile')" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
|
|
<p>{{ content.profile.introduction }}</p>
|
|
<dl class="profile-facts">
|
|
<template v-for="fact in content.profile.screeningFacts" :key="fact.label">
|
|
<dt>{{ fact.label }}</dt>
|
|
<dd>{{ fact.value }}</dd>
|
|
</template>
|
|
</dl>
|
|
</PortfolioSection>
|
|
|
|
<PortfolioSection id="experience" :exit="exitFor('experience')" :next-label="nextLabelFor('experience')" :next-exit="nextExitFor('experience')" :layout="sectionLayouts.experience" :heading="sectionLabel('experience')">
|
|
<ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" />
|
|
</PortfolioSection>
|
|
|
|
<PortfolioSection id="infrastructure" :exit="exitFor('infrastructure')" :next-label="nextLabelFor('infrastructure')" :next-exit="nextExitFor('infrastructure')" :layout="sectionLayouts.infrastructure" :heading="sectionLabel('infrastructure')">
|
|
<InfrastructureDiagram :infrastructure="content.infrastructure" />
|
|
</PortfolioSection>
|
|
|
|
<PortfolioSection id="selected-builds" :exit="exitFor('selected-builds')" :next-label="nextLabelFor('selected-builds')" :next-exit="nextExitFor('selected-builds')" :layout="sectionLayouts['selected-builds']" eyebrow="Selected work" :heading="sectionLabel('selected-builds')">
|
|
<template #lead>
|
|
<ProjectCaseStudy
|
|
v-if="content.projects.selected[0]"
|
|
:project="content.projects.selected[0]"
|
|
:exit="exitFor('selected-builds')"
|
|
:index="0"
|
|
priority="primary"
|
|
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
|
|
/>
|
|
</template>
|
|
<div class="project-stage__projects">
|
|
<ProjectCaseStudy
|
|
v-for="(project, index) in content.projects.selected.slice(1)"
|
|
:key="project.id"
|
|
:project="project"
|
|
:exit="exitFor('selected-builds')"
|
|
:index="index + 1"
|
|
:priority="index === 0 ? 'primary' : 'supporting'"
|
|
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
|
|
/>
|
|
</div>
|
|
</PortfolioSection>
|
|
|
|
<PortfolioSection id="additional-work" :exit="exitFor('additional-work')" :next-label="nextLabelFor('additional-work')" :next-exit="nextExitFor('additional-work')" :layout="sectionLayouts['additional-work']" :heading="sectionLabel('additional-work')">
|
|
<div class="compact-projects">
|
|
<ProjectCard
|
|
v-for="(project, index) in content.projects.additional"
|
|
:key="project.id"
|
|
:project="project"
|
|
:exit="exitFor('additional-work')"
|
|
:index="index"
|
|
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
|
|
/>
|
|
</div>
|
|
</PortfolioSection>
|
|
|
|
<PortfolioSection id="toolkit" :exit="exitFor('toolkit')" :next-label="nextLabelFor('toolkit')" :next-exit="nextExitFor('toolkit')" :layout="sectionLayouts.toolkit" :heading="sectionLabel('toolkit')">
|
|
<ToolkitGrid :groups="content.toolkit" />
|
|
</PortfolioSection>
|
|
|
|
<PortfolioSection id="contact" :exit="exitFor('contact')" :next-label="nextLabelFor('contact')" :next-exit="nextExitFor('contact')" :layout="sectionLayouts.contact" eyebrow="Destination" :heading="sectionLabel('contact')" :data-trip="tripLog">
|
|
<footer>
|
|
<ContactDestination :contact="content.contact" />
|
|
</footer>
|
|
</PortfolioSection>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
<span class="content-focus-cue" aria-hidden="true">Portfolio content</span>
|
|
<DriveControls
|
|
:policy="displayedPolicy"
|
|
:maximum-quality="controlsReady ? motion.maximumQuality.value : 'high'"
|
|
:scene-status="displayedSceneState"
|
|
:audio-state="audio.state.value"
|
|
:audio-muted="audio.muted.value"
|
|
:audio-volume="controlsReady ? motion.volume.value : 0.4"
|
|
@pause="pauseMotion"
|
|
@resume="resumeMotion"
|
|
@enable="enableMotion"
|
|
@quality-change="setQuality"
|
|
@audio-start="startDrive"
|
|
@audio-resume="audio.resumeFromGesture"
|
|
@audio-mute-toggle="toggleAudioMuted"
|
|
@audio-volume-change="updateAudioVolume"
|
|
/>
|
|
</template>
|