line-and-surface/app/src/components/SVGContent.vue

410 lines
13 KiB
Vue

<template>
<div class="svg-content">
<div class="content" ref="root">
<div class="video-scrolls">
<VideoScroll v-for="scroll in scrolls" :definition="scroll"/>
</div>
</div>
<AudioArea v-for="audio in audioAreas" :definition="audio" :bbox="bbox"/>
<div class="devpanel">
<div>
<span>Current viewport position:</span>
<span>{{ Math.round(bbox.x) }}x{{ Math.round(bbox.y) }}</span>
</div>
<div>
<span>Current cursor position:</span>
<span>{{ Math.round(mousePosition.x) }}x{{ Math.round(mousePosition.y) }}</span>
</div>
<div>
<span>Zoom level:</span><span>{{ (Math.round(bbox.z * 1000) / 1000) }}</span>
</div>
<label>
<input v-model="showInternal" type="checkbox">
<label>Show internal elements</label>
</label>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent, onMounted, reactive, ref} from "vue";
import createPanZoom, {PanZoom} from "panzoom";
import VideoScroll, {VideoScrollDef, VideoScrollDirection} from "@/components/VideoScroll.vue";
import AudioArea, {AudioAreaDef} from "@/components/AudioArea.vue";
import Stats from "stats.js";
import {rotate} from "@/utils";
export default defineComponent({
name: "SVGContent",
components: {AudioArea, VideoScroll},
props: {
url: {
type: String,
required: true
}
},
data() {
return {
showInternal: false
};
},
watch: {
showInternal(value) {
Array.from(this.root!.getElementsByClassName("internal")).forEach((el) => {
(el as SVGElement).style.visibility = value ? "visible" : "hidden";
});
}
},
setup(props, {emit}) {
const root = ref<HTMLDivElement | null>(null);
const panzoom = ref<null | PanZoom>(null);
const anchors = ref<SVGRectElement[]>([]);
const scrolls = ref<VideoScrollDef[]>([]);
const panToAnchor = ref();
const audioAreas = ref<AudioAreaDef[]>([]);
const bbox = reactive({
x: ref(0),
y: ref(0),
w: ref(0),
h: ref(0),
z: ref(1)
});
const mousePosition = reactive({
x: ref(0),
y: ref(0)
});
onMounted(async () => {
const element = root.value as unknown as HTMLDivElement;
console.info("[SVG] Initializing.");
// Fetch & load SVG
console.info(`[SVG] Fetching "${props.url}..."`);
const fetchResult = await fetch(props.url);
console.debug("[SVG] Fetched, parsing...");
const svgParsed = new DOMParser().parseFromString(await fetchResult.text(), "image/svg+xml") as Document;
console.debug("[SVG] Parsed.");
const svg = element.appendChild(svgParsed.firstElementChild as Element) as any;
// Set document background
const pageColor = svg.getElementById("base")?.attributes.getNamedItem("pagecolor");
if (pageColor) {
console.debug(`[SVG] Found pageColor attribute: ${pageColor.value}`);
emit("setBackground", pageColor.value);
}
// PanZoom
const pz = createPanZoom(element, {
smoothScroll: false,
minZoom: 0.05,
maxZoom: 3637937,
zoomSpeed: 0.05,
zoomDoubleClickSpeed: 1,
onDoubleClick: function (e) {
if (!document.fullscreenElement) {
console.debug("[SVG] Fullscreen requested.");
document.body.requestFullscreen();
} else {
console.debug("[SVG] Fullscreen exited.");
document.exitFullscreen();
}
return true;
}
});
panzoom.value = pz;
// Calculate SVG-unit bounding box, update transform
pz.on("transform", function (_) {
const transform = pz.getTransform();
const currentRatio = svg.clientWidth * transform.scale / svg.viewBox.baseVal.width;
bbox.x = transform.x / currentRatio * -1;
bbox.y = transform.y / currentRatio * -1;
bbox.w = window.innerWidth / currentRatio;
bbox.h = window.innerHeight / currentRatio;
bbox.z = transform.scale;
});
function panToElement(target: SVGRectElement, smooth: boolean) {
console.debug(`[SVG] Panning to element: #${target.id}`);
const transform = pz.getTransform();
const currentRatio = svg.clientWidth * transform.scale / svg.viewBox.baseVal.width;
const ratio = svg.clientWidth / svg.viewBox.baseVal.width;
const targetScale = window.innerWidth / (target.width.baseVal.value * ratio);
const svgTargetX = (target.x.baseVal.value + target.width.baseVal.value / 2) * currentRatio;
const svgTargetY = (target.y.baseVal.value + target.height.baseVal.value / 2) * currentRatio;
if (smooth) {
pz.smoothMoveTo(
svgTargetX * -1 + window.innerWidth / 2,
svgTargetY * -1 + window.innerHeight / 2,
);
setTimeout(() => {
const finalTransform = pz.getTransform();
pz.smoothZoomAbs(
(svgTargetX + finalTransform.x),
(svgTargetY + finalTransform.y),
targetScale
);
}, 400 * 2);
} else {
pz.moveTo(
svgTargetX * -1 + window.innerWidth / 2,
svgTargetY * -1 + window.innerHeight / 2,
);
pz.zoomAbs(window.innerWidth / 2, window.innerHeight / 2, targetScale);
}
}
panToAnchor.value = (anchor: SVGRectElement) => {
panToElement(anchor, true);
};
// Pan to start element
const start = processStart(svg);
if (start) {
console.info("[SVG] Found start element.");
panToElement(start, false);
window.addEventListener("keydown", (ev) => {
if (ev.key === " ") {
panToElement(start, true);
}
});
}
// Anchors
console.debug("[SVG] Processing anchors.");
anchors.value = processAnchors(svg);
console.info(`[SVG] Found ${anchors.value.length} anchors.`);
// Audio areas
console.debug("[SVG] Processing audio areas.");
audioAreas.value = processAudio(svg);
console.info(`[SVG] Found ${audioAreas.value.length} audio areas.`);
// Videoscrolls
console.debug("[SVG] Processing video scrolls.");
scrolls.value = await processScrolls(svg);
console.info(`[SVG] Found ${scrolls.value.length} video scrolls.`);
// Debug Stats
let stats: Stats | undefined;
if (process.env.VUE_APP_DEMO) {
console.info("[SVG] DEMO mode active, turning on stats & dev panel.");
stats = new Stats();
document.body.appendChild(stats.dom);
} else {
Array.from(document.body.getElementsByClassName("dev")).forEach((el) => {
(el as HTMLElement).style.display = "none";
});
}
// Animations: FPS Counter, Edge scrolling
let mouse: MouseEvent | undefined;
window.addEventListener("mousemove", (ev) => {
mouse = ev;
const transform = pz.getTransform();
const currentRatio = svg.clientWidth * transform.scale / svg.viewBox.baseVal.width;
mousePosition.x = (mouse.clientX - transform.x) / currentRatio;
mousePosition.y = (mouse.clientY - transform.y) / currentRatio;
});
function animate() {
if (stats) {
stats.begin();
}
// Edge scrolling
const MOVE_EDGE_X = window.innerWidth * .25;
const MOVE_EDGE_Y = window.innerHeight * .25;
const MAX_SPEED = 20;
if (document.fullscreenElement && mouse) {
let horizontalShift: number;
let verticalShift: number;
const transform = pz.getTransform();
if (mouse.clientX < MOVE_EDGE_X || mouse.clientX > window.innerWidth - MOVE_EDGE_X) {
const horizontalEdgeDistance =
(mouse.clientX < window.innerWidth / 2) ? mouse.clientX : (mouse.clientX - window.innerWidth);
const horizontalRatio = (MOVE_EDGE_X - Math.abs(horizontalEdgeDistance)) / MOVE_EDGE_X;
const direction = mouse.clientX < MOVE_EDGE_X ? 1 : -1;
horizontalShift = horizontalRatio * direction * MAX_SPEED;
} else {
horizontalShift = 0;
}
if (mouse.clientY < MOVE_EDGE_Y || mouse.clientY > window.innerHeight - MOVE_EDGE_Y) {
const verticalEdgeDistance =
(mouse.clientY < window.innerHeight / 2) ? mouse.clientY : (mouse.clientY - window.innerHeight);
const verticalRatio = (MOVE_EDGE_Y - Math.abs(verticalEdgeDistance)) / MOVE_EDGE_Y;
const direction = mouse.clientY < MOVE_EDGE_Y ? 1 : -1;
verticalShift = verticalRatio * direction * MAX_SPEED;
} else {
verticalShift = 0;
}
if (horizontalShift || verticalShift) {
pz.moveTo(transform!.x + horizontalShift, transform!.y + verticalShift);
}
}
if (stats) {
stats.end();
}
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
});
return {
root,
panzoom,
anchors,
panToAnchor,
scrolls,
audioAreas,
bbox,
mousePosition
};
},
});
function processAnchors(document: XMLDocument): SVGRectElement[] {
let result = [];
let i = 1;
while (true) {
let anchor = document.getElementById(`anchor_${i}`) as SVGRectElement | null;
if (anchor === null) {
break;
}
console.debug(`[SVG/ANCHORS] Found anchor #${anchor.id}.`);
anchor.classList.add("internal");
result.push(anchor);
i++;
}
return result;
}
async function processScrolls(svg: XMLDocument): Promise<VideoScrollDef[]> {
const ratio = (svg as any).clientWidth / (svg as any).viewBox.baseVal.width;
return Promise.all(
Array.from(svg.getElementsByTagName("image"))
.filter((el) => Array.from(el.children).some((el) => el.tagName == "desc"))
.map(async (el) => {
const descNode = Array.from(el.children).find((el) => el.tagName == "desc");
console.debug(`[SVG/VIDEOSCROLLS] Found video scroll #${el.id}: ${descNode?.textContent}`);
const [directionString, filesURL] = descNode!.textContent!.split("\n");
if (!Object.values(VideoScrollDirection).includes(directionString as VideoScrollDirection)) {
throw new Error("Unknown direction string.");
}
console.debug(`[SVG/VIDEOSCROLLS] Fetching ${filesURL}...`);
const fileFetch = await fetch(`content/${filesURL}`);
const preURL = fileFetch.url.replace(/\/files.lst$/, "");
const files = (await fileFetch.text()).split("\n").filter(Boolean).map((str) => `${preURL}/${str}`);
let x = el.x.baseVal.value;
let y = el.y.baseVal.value;
let w = el.width.baseVal.value;
let h = el.height.baseVal.value;
let angle = 0;
const transform = el.attributes.getNamedItem("transform");
const rotateResult = /rotate\((-?[0-9.]+)\)/.exec(transform?.value || "");
if (rotateResult) {
angle = parseFloat(rotateResult[1]);
const [ncx, ncy] = rotate(x + w / 2, y + h / 2, 0, 0, angle);
x = ncx - w / 2;
y = ncy - h / 2;
}
return {
top: y * ratio,
left: x * ratio,
angle,
width: w * ratio,
height: h * ratio,
direction: directionString as VideoScrollDirection,
files
};
})
);
}
function processAudio(svg: XMLDocument): AudioAreaDef[] {
return Array.from(svg.getElementsByTagName("circle"))
.filter((el) => Array.from(el.children).some((el) => el.tagName == "desc"))
.map((el) => {
const descNode = Array.from(el.children).find((el) => el.tagName == "desc");
console.debug(`[SVG/AUDIOAREAS] Found audio area #${el.id}: ${descNode?.textContent}`);
const audioSrc = descNode!.textContent!.trim();
el.classList.add("internal");
return {
cx: el.cx.baseVal.value,
cy: el.cy.baseVal.value,
radius: el.r.baseVal.value,
src: `content/${audioSrc}`,
};
});
}
function processStart(svg: XMLDocument): SVGRectElement | null {
const start = svg.getElementById("start");
if (start) {
start.classList.add("internal");
}
return start as (SVGRectElement | null);
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style>
.svg-content svg {
overflow: visible;
}
.svg-content svg .internal {
visibility: hidden;
}
.devpanel {
position: fixed;
top: 0;
right: 0;
z-index: 999;
color: white;
background: #000000aa;
border: 2px solid white;
font-family: monospace;
padding: 1em 2em;
}
.devpanel div {
display: flex;
justify-content: space-between;
}
.devpanel label {
float: right;
}
.devpanel div span {
margin: 0 .5em;
}
</style>