426 lines
18 KiB
Vue
426 lines
18 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 { useCardEntrances } from '@/composables/useCardEntrances';
|
|
import { useHeadlightBeam } from '@/composables/useHeadlightBeam';
|
|
import { useJourneySections } from '@/composables/useJourneySections';
|
|
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
|
import { useMaintenanceWindow } from '@/composables/useMaintenanceWindow';
|
|
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
|
import { useRouteMemory } from '@/composables/useRouteMemory';
|
|
import { useRouteLog } from '@/composables/useRouteLog';
|
|
import { useBillboard } from '@/composables/useBillboard';
|
|
import { useDashcam } from '@/composables/useDashcam';
|
|
import { useRelay } from '@/composables/useRelay';
|
|
import { useLivingTab } from '@/composables/useLivingTab';
|
|
import { assetUrl } from '@/content/asset-url';
|
|
import { signRouteFor } from '@/journey/sign-routes';
|
|
import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
|
|
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;
|
|
/* The 03:12 window (Infrastructure): the plate's sub-line and the diagram steps. */
|
|
const maintenanceSubline = useMaintenanceWindow(journey);
|
|
/* MILE MARKERS (Experience, Profile): the ROUTE LOG odometer on the same sub-line. */
|
|
const routeLogSubline = useRouteLog(journey, () => props.content);
|
|
/* DRIVE-IN (Selected builds): the floodlit billboard follows the case studies. */
|
|
useBillboard(journey, () => props.content.projects.selected, props.manifest, exitFor('selected-builds'));
|
|
useRelay(journey, () => props.content.toolkit);
|
|
useCardEntrances();
|
|
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);
|
|
/* END OF SHIFT: parked at the destination the plate's sub-line reads the
|
|
shift's end. Client-only, like the 03:12 window, so the server HTML hydrates
|
|
unchanged. */
|
|
const hudSubline = computed(() => (controlsReady.value && journey.value.arrived ? END_OF_SHIFT_SUBLINE : '')
|
|
|| maintenanceSubline.value
|
|
|| routeLogSubline.value);
|
|
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);
|
|
/* HIGH BEAM: the cursor is the car's headlights, in the scene and on the page. */
|
|
const headlight = useHeadlightBeam({
|
|
motionAllowed: computed(() => motion.policy.value.allowSceneRequest && pageLifecycle.value === 'active'),
|
|
quality: actualQuality,
|
|
activeSectionId: () => journey.value.activeSectionId,
|
|
});
|
|
/* DASHCAM: a full-screen photo mode over the live scene (lazy frame). */
|
|
const dashcam = useDashcam({
|
|
allowed: computed(() => controlsReady.value
|
|
&& motion.policy.value.reason === 'full'
|
|
&& motion.policy.value.allowSceneRequest
|
|
&& actualQuality.value !== 'static'
|
|
&& pageLifecycle.value === 'active'),
|
|
sceneRunning: computed(() => sceneState.value === 'running'),
|
|
drift: () => actualQuality.value !== 'low' && matchMedia('(pointer: fine)').matches,
|
|
});
|
|
/* PARKING LIGHTS: the tab title and favicon follow the drive (after mount). */
|
|
useLivingTab({
|
|
routeProgress: () => journey.value.routeProgress,
|
|
activeSectionId: () => journey.value.activeSectionId,
|
|
label: () => sectionLabel(journey.value.activeSectionId),
|
|
arrived: () => journey.value.arrived,
|
|
dashcamOpen: dashcam.open,
|
|
animated: () => motion.policy.value.reason === 'full' && actualQuality.value !== 'static',
|
|
scenePending: () => motion.policy.value.allowSceneRequest && (sceneState.value === 'idle' || sceneState.value === 'loading'),
|
|
});
|
|
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 => {
|
|
headlight.sceneFrame();
|
|
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"
|
|
:credit="`${content.identity.name} · ${content.identity.role} · ${content.identity.location.city}, ${content.identity.location.country}`"
|
|
:sample-frame="sampleSceneFrame"
|
|
:sample-beam="headlight.sampleBeam"
|
|
@state-change="updateSceneState"
|
|
/>
|
|
<JourneyHud
|
|
:journey="journey"
|
|
:section-label="activeSectionLabel"
|
|
:exit="exitFor(journey.activeSectionId)"
|
|
:next-label="nextLabelFor(journey.activeSectionId)"
|
|
:next-exit="nextExitFor(journey.activeSectionId)"
|
|
:subline="hudSubline"
|
|
/>
|
|
<header class="site-header">
|
|
<SiteNavigation
|
|
:items="canonicalNavigation"
|
|
:journey="journey"
|
|
:visited="route.visited.value"
|
|
:resume-id="resume?.id"
|
|
: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" :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"
|
|
:dashcam="dashcam.available.value"
|
|
@pause="pauseMotion"
|
|
@resume="resumeMotion"
|
|
@enable="enableMotion"
|
|
@quality-change="setQuality"
|
|
@audio-start="startDrive"
|
|
@audio-resume="audio.resumeFromGesture"
|
|
@audio-mute-toggle="toggleAudioMuted"
|
|
@audio-volume-change="updateAudioVolume"
|
|
@dashcam="dashcam.show"
|
|
@dashcam-prefetch="dashcam.prefetch"
|
|
/>
|
|
<component
|
|
:is="dashcam.frame.value"
|
|
v-if="dashcam.open.value && dashcam.frame.value"
|
|
:journey="journey"
|
|
:section-label="activeSectionLabel"
|
|
:name="content.identity.name"
|
|
:city="content.identity.location.city"
|
|
:scene-ready="sceneState === 'running'"
|
|
@close="dashcam.close"
|
|
/>
|
|
</template>
|