309 lines
12 KiB
Vue
309 lines
12 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 { useJourneySections } from '@/composables/useJourneySections';
|
|
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
|
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
|
import { assetUrl } from '@/content/asset-url';
|
|
import type { PortfolioContent, PublicAssetManifest, SectionId, SectionLayout } from '@/content/types';
|
|
import type { SceneHostState } from '@/composables/useSceneController';
|
|
import type { MotionPolicy } from '@/journey/motion-policy';
|
|
import type { 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;
|
|
};
|
|
|
|
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 pendingAudioDestination = shallowRef<SectionId | null>(null);
|
|
let audioNavigationEpoch = -1;
|
|
watch(
|
|
[navigation.state, () => journey.value.activeSectionId],
|
|
([navigationState, activeSectionId]) => {
|
|
if (navigationState.epoch !== audioNavigationEpoch) {
|
|
audioNavigationEpoch = navigationState.epoch;
|
|
pendingAudioDestination.value = navigationState.source === 'passive-scroll'
|
|
? null
|
|
: navigationState.destinationId;
|
|
}
|
|
if (pendingAudioDestination.value === activeSectionId) {
|
|
pendingAudioDestination.value = null;
|
|
}
|
|
},
|
|
{ flush: 'sync', immediate: true },
|
|
);
|
|
const audioSectionId = computed(() => (
|
|
pendingAudioDestination.value ?? journey.value.activeSectionId
|
|
));
|
|
const soundtrackSources = computed<SoundtrackSources | null>(() => {
|
|
const soundtrack = props.content.soundtrack;
|
|
if (soundtrack === undefined) return null;
|
|
return {
|
|
intro: [
|
|
{ url: assetUrl(props.manifest, soundtrack.intro.opusAssetId), mediaType: 'audio/ogg' },
|
|
{ url: assetUrl(props.manifest, soundtrack.intro.mp3AssetId), mediaType: 'audio/mpeg' },
|
|
],
|
|
main: [
|
|
{ url: assetUrl(props.manifest, soundtrack.main.opusAssetId), mediaType: 'audio/ogg' },
|
|
{ url: assetUrl(props.manifest, soundtrack.main.mp3AssetId), mediaType: 'audio/mpeg' },
|
|
],
|
|
};
|
|
});
|
|
const audio = useAudioEngine({
|
|
activeSectionId: audioSectionId,
|
|
intensity: computed(() => journey.value.acceleration),
|
|
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),
|
|
);
|
|
|
|
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"
|
|
/>
|
|
<header class="site-header">
|
|
<SiteNavigation
|
|
:items="canonicalNavigation"
|
|
:journey="journey"
|
|
:on-section-link-click="navigation.onSectionLinkClick"
|
|
/>
|
|
</header>
|
|
|
|
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
|
|
<HeroJourney :identity="content.identity" @explore="navigation.onSectionLinkClick" />
|
|
|
|
<PortfolioSection id="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" :layout="sectionLayouts.experience" :heading="sectionLabel('experience')">
|
|
<ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" />
|
|
</PortfolioSection>
|
|
|
|
<PortfolioSection id="infrastructure" :layout="sectionLayouts.infrastructure" :heading="sectionLabel('infrastructure')">
|
|
<InfrastructureDiagram :infrastructure="content.infrastructure" />
|
|
</PortfolioSection>
|
|
|
|
<PortfolioSection id="selected-builds" :layout="sectionLayouts['selected-builds']" eyebrow="Selected work" :heading="sectionLabel('selected-builds')">
|
|
<div class="project-stage__projects">
|
|
<ProjectCaseStudy
|
|
v-for="(project, index) in content.projects.selected"
|
|
:key="project.id"
|
|
:project="project"
|
|
:priority="index < 2 ? 'primary' : 'supporting'"
|
|
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
|
|
/>
|
|
</div>
|
|
</PortfolioSection>
|
|
|
|
<PortfolioSection id="additional-work" :layout="sectionLayouts['additional-work']" :heading="sectionLabel('additional-work')">
|
|
<div class="compact-projects">
|
|
<ProjectCard
|
|
v-for="project in content.projects.additional"
|
|
:key="project.id"
|
|
:project="project"
|
|
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
|
|
/>
|
|
</div>
|
|
</PortfolioSection>
|
|
|
|
<PortfolioSection id="toolkit" :layout="sectionLayouts.toolkit" :heading="sectionLabel('toolkit')">
|
|
<ToolkitGrid :groups="content.toolkit" />
|
|
</PortfolioSection>
|
|
|
|
<PortfolioSection id="contact" :layout="sectionLayouts.contact" eyebrow="Destination" :heading="sectionLabel('contact')">
|
|
<footer>
|
|
<ContactDestination :contact="content.contact" />
|
|
</footer>
|
|
</PortfolioSection>
|
|
</main>
|
|
<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>
|