feat: integrate accessible journey world and controls
This commit is contained in:
@@ -1,14 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
|
||||
import ContactDestination from './ContactDestination.vue';
|
||||
import DriveControls from './DriveControls.vue';
|
||||
import ExperienceTimeline from './ExperienceTimeline.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 { useJourneySections } from '@/composables/useJourneySections';
|
||||
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
||||
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
||||
import type { PortfolioContent, SectionId } from '@/content/types';
|
||||
import type { SceneHostState } from '@/composables/useSceneController';
|
||||
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
|
||||
|
||||
const props = defineProps<{ content: Readonly<PortfolioContent> }>();
|
||||
|
||||
@@ -27,15 +36,98 @@ const sectionLabel = (id: SectionId): string => {
|
||||
};
|
||||
|
||||
const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id, label: sectionLabel(id) })));
|
||||
const motion = useMotionPreferences();
|
||||
const navigation = useKeyboardNavigation(canonicalSectionIds);
|
||||
const journey = useJourneySections(
|
||||
canonicalSectionIds,
|
||||
navigation.state,
|
||||
computed(() => motion.policy.value.allowSceneRequest),
|
||||
);
|
||||
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 actualQuality = computed(() => 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 displayedQuality = computed<QualityTier>(() => controlsReady.value ? actualQuality.value : 'high');
|
||||
const displayedSceneState = computed(() => controlsReady.value ? sceneState.value : 'running');
|
||||
let removePageListeners = (): void => {};
|
||||
|
||||
watch(
|
||||
() => journey.value.activeSectionId,
|
||||
(id) => navigation.notePassiveSection(id),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => motion.policy.value.maximumTier,
|
||||
(tier, previous) => {
|
||||
if (tier !== previous) adaptive.setMaximum(tier, 'manual');
|
||||
},
|
||||
);
|
||||
|
||||
const setQuality = (tier: import('@/journey/motion-policy').QualityTier): void => {
|
||||
motion.setMaximumQuality(tier);
|
||||
adaptive.setMaximum(tier, 'manual');
|
||||
};
|
||||
|
||||
const enableMotion = (): void => {
|
||||
const tier = motion.maximumQuality.value === 'static' ? 'high' : motion.maximumQuality.value;
|
||||
motion.setMaximumQuality(tier);
|
||||
motion.setPaused(false);
|
||||
adaptive.setMaximum(tier, 'manual');
|
||||
};
|
||||
|
||||
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"
|
||||
@state-change="sceneState = $event"
|
||||
/>
|
||||
<JourneyHud
|
||||
:journey="journey"
|
||||
:section-label="activeSectionLabel"
|
||||
:quality="displayedQuality"
|
||||
/>
|
||||
<header class="site-header">
|
||||
<h1>{{ content.identity.name }}</h1>
|
||||
<p>{{ content.identity.role }}</p>
|
||||
<p class="print-portfolio-label" aria-hidden="true">Portfolio</p>
|
||||
<SiteNavigation :items="canonicalNavigation" />
|
||||
<SiteNavigation
|
||||
:items="canonicalNavigation"
|
||||
:journey="journey"
|
||||
:on-section-link-click="navigation.onSectionLinkClick"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
|
||||
@@ -79,4 +171,13 @@ const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id
|
||||
</footer>
|
||||
</PortfolioSection>
|
||||
</main>
|
||||
<DriveControls
|
||||
:policy="displayedPolicy"
|
||||
:maximum-quality="controlsReady ? motion.maximumQuality.value : 'high'"
|
||||
:scene-status="displayedSceneState"
|
||||
@pause="motion.setPaused(true)"
|
||||
@resume="motion.setPaused(false)"
|
||||
@enable="enableMotion"
|
||||
@quality-change="setQuality"
|
||||
/>
|
||||
</template>
|
||||
|
||||
64
src/components/DriveControls.vue
Normal file
64
src/components/DriveControls.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
|
||||
|
||||
const props = defineProps<{
|
||||
policy: MotionPolicy;
|
||||
maximumQuality: QualityTier;
|
||||
sceneStatus: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
pause: [];
|
||||
resume: [];
|
||||
enable: [];
|
||||
qualityChange: [quality: QualityTier];
|
||||
}>();
|
||||
|
||||
const action = computed(() => {
|
||||
if (props.policy.reason === 'visitor-pause') return { label: 'Resume motion', event: 'resume' as const };
|
||||
if (props.policy.reason === 'saved-static') return { label: 'Enable motion', event: 'enable' as const };
|
||||
if (props.policy.reason === 'full') return { label: 'Pause motion', event: 'pause' as const };
|
||||
return null;
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (props.policy.reason === 'system-reduce') return 'Motion: static — reduced motion requested';
|
||||
if (props.policy.reason === 'data-saver') return 'Motion: static — data saver is active';
|
||||
if (props.policy.reason === 'visitor-pause') return 'Motion: paused';
|
||||
if (props.policy.reason === 'saved-static') return 'Motion: static';
|
||||
if (props.sceneStatus === 'failed' || props.sceneStatus === 'static') return 'Motion: static — scene unavailable';
|
||||
if (props.sceneStatus === 'loading' || props.sceneStatus === 'idle') return 'Motion: preparing';
|
||||
return 'Motion: active';
|
||||
});
|
||||
|
||||
const runAction = (): void => {
|
||||
const event = action.value?.event;
|
||||
if (event === 'pause') emit('pause');
|
||||
else if (event === 'resume') emit('resume');
|
||||
else if (event === 'enable') emit('enable');
|
||||
};
|
||||
|
||||
const changeQuality = (event: Event): void => {
|
||||
const value = (event.currentTarget as HTMLSelectElement).value;
|
||||
if (value === 'high' || value === 'medium' || value === 'low' || value === 'static') {
|
||||
emit('qualityChange', value);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="drive-controls">
|
||||
<button v-if="action" type="button" @click="runAction">{{ action.label }}</button>
|
||||
<label>
|
||||
<span>Quality</span>
|
||||
<select :value="maximumQuality" aria-label="Quality control" @change="changeQuality">
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="static">Static</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="drive-controls__status" role="status">{{ statusText }}</p>
|
||||
</div>
|
||||
</template>
|
||||
23
src/components/JourneyHud.vue
Normal file
23
src/components/JourneyHud.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { JourneySnapshot } from '@/journey/types';
|
||||
import type { QualityTier } from '@/journey/motion-policy';
|
||||
|
||||
const props = defineProps<{
|
||||
journey: JourneySnapshot;
|
||||
sectionLabel: string;
|
||||
quality: QualityTier;
|
||||
}>();
|
||||
|
||||
const routePercent = computed(() => `${Math.round(Math.min(1, Math.max(0, props.journey.routeProgress)) * 100)}%`);
|
||||
const qualityLabel = computed(() => props.quality.charAt(0).toUpperCase() + props.quality.slice(1));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="journey-hud" aria-hidden="true">
|
||||
<span class="journey-hud__route">ROUTE {{ routePercent }}</span>
|
||||
<span class="journey-hud__section">{{ sectionLabel }}</span>
|
||||
<span class="journey-hud__quality">{{ qualityLabel }}</span>
|
||||
<strong v-if="journey.arrived" class="journey-hud__arrival">ARRIVED</strong>
|
||||
</aside>
|
||||
</template>
|
||||
42
src/components/SceneCanvas.vue
Normal file
42
src/components/SceneCanvas.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, toRef, watch } from 'vue';
|
||||
import { useSceneController, type SceneHostState } from '@/composables/useSceneController';
|
||||
import type { JourneySnapshot } from '@/journey/types';
|
||||
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
|
||||
|
||||
const props = defineProps<{
|
||||
journey: JourneySnapshot;
|
||||
policy: MotionPolicy;
|
||||
quality: QualityTier;
|
||||
sceneDescription: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
stateChange: [state: SceneHostState];
|
||||
}>();
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
const clientReady = ref(false);
|
||||
const journey = toRef(props, 'journey');
|
||||
const policy = toRef(props, 'policy');
|
||||
const quality = toRef(props, 'quality');
|
||||
const scene = useSceneController({ canvas, journey, policy, quality });
|
||||
const showCanvas = computed(() => (
|
||||
clientReady.value
|
||||
&& policy.value.allowSceneRequest
|
||||
&& quality.value !== 'static'
|
||||
&& !scene.error.value
|
||||
));
|
||||
|
||||
watch(scene.state, (state) => emit('stateChange', state), { immediate: true });
|
||||
onMounted(() => { clientReady.value = true; });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p id="journey-scene-description" class="visually-hidden">{{ sceneDescription }}</p>
|
||||
<div class="journey-world" aria-hidden="true" :data-scene-state="scene.state">
|
||||
<img class="journey-poster" alt="" width="1600" height="900">
|
||||
<canvas v-if="showCanvas" ref="canvas" class="journey-canvas" aria-hidden="true" />
|
||||
</div>
|
||||
<slot />
|
||||
</template>
|
||||
@@ -1,20 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from "vue";
|
||||
import type { SectionId } from "@/content/types";
|
||||
import { useJourneySections } from "@/composables/useJourneySections";
|
||||
import { useKeyboardNavigation } from "@/composables/useKeyboardNavigation";
|
||||
import type { JourneySnapshot } from '@/journey/types';
|
||||
|
||||
const props = defineProps<{
|
||||
items: ReadonlyArray<{ id: SectionId; label: string }>;
|
||||
journey: JourneySnapshot;
|
||||
onSectionLinkClick: (event: MouseEvent) => void;
|
||||
}>();
|
||||
const canonicalIds = props.items.map((item) => item.id);
|
||||
const navigation = useKeyboardNavigation(canonicalIds);
|
||||
const journey = useJourneySections(canonicalIds, navigation.state);
|
||||
|
||||
watch(
|
||||
() => journey.value.activeSectionId,
|
||||
(id) => navigation.notePassiveSection(id),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,9 +16,9 @@ watch(
|
||||
<a
|
||||
:href="`#${item.id}`"
|
||||
:aria-current="
|
||||
journey.activeSectionId === item.id ? 'page' : undefined
|
||||
props.journey.activeSectionId === item.id ? 'page' : undefined
|
||||
"
|
||||
@click="navigation.onSectionLinkClick"
|
||||
@click="props.onSectionLinkClick"
|
||||
>{{ item.label }}</a
|
||||
>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user