Site: app regions with screenshots, props, downloads, English and Chinese, narrow layout; hit layer follows draw order
This commit is contained in:
29 files changed
+1423
-97
No files matched your search
@@ -0,0 +1,12 @@
|
||||
// The film's tempo: 112 BPM, landings on sixteenths.
|
||||
const BEAT = 60 / 112;
|
||||
const STEP = BEAT / 4;
|
||||
|
||||
/** Times parts to land beat by beat, blocks on sixteenths. */
|
||||
export function grow(parts: SVGGElement[][]): void {
|
||||
let beat = 1;
|
||||
for (const blocks of parts) {
|
||||
blocks.forEach((g, i) => g.style.setProperty("--land", `${(beat * BEAT + i * STEP).toFixed(3)}s`));
|
||||
beat += Math.ceil(blocks.length / 4);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hit-face.ground {
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.block.lifted {
|
||||
translate: 0 calc(-1 * var(--step));
|
||||
}
|
||||
@@ -55,3 +59,8 @@
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Off-screen models wait until they scroll near. */
|
||||
.grow:not(.seen) .block {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
@@ -110,3 +110,19 @@ export function stage(blocks: SVGGElement[]): SVGSVGElement {
|
||||
svg.setAttribute("class", "stage");
|
||||
return svg;
|
||||
}
|
||||
|
||||
/** Pins an image across a block's left face. */
|
||||
export function screen(g: SVGGElement, href: string, width: number, height: number): void {
|
||||
const b = boxes.get(g);
|
||||
if (!b) throw new Error("not a block");
|
||||
const [e, f] = project(b.x, b.y + b.d, b.z + b.h);
|
||||
const sx = (b.w * UNIT) / width;
|
||||
const sy = (b.h * UNIT) / height;
|
||||
const image = document.createElementNS(SVG, "image");
|
||||
image.setAttribute("href", href);
|
||||
image.setAttribute("width", String(width));
|
||||
image.setAttribute("height", String(height));
|
||||
image.setAttribute("preserveAspectRatio", "none");
|
||||
image.setAttribute("transform", `matrix(${COS * sx} ${SIN * sx} 0 ${sy} ${e} ${f})`);
|
||||
g.append(image);
|
||||
}
|
||||
+48
-19
@@ -1,38 +1,67 @@
|
||||
import "./style.css";
|
||||
import "./iso.css";
|
||||
import { block, stage } from "./iso.ts";
|
||||
import { grow } from "./beat.ts";
|
||||
import { model } from "./model.ts";
|
||||
import { touch } from "./touch.ts";
|
||||
import { regions, reveal } from "./regions.ts";
|
||||
import { said, speak, type Lang } from "./text.ts";
|
||||
|
||||
/** 112 BPM, the film's tempo; blocks land on sixteenths. */
|
||||
const BEAT = 60 / 112;
|
||||
const STEP = BEAT / 4;
|
||||
const RELEASE = "https://github.com/ZacharyZhang-NY/ElyOffice/releases/download/v0.1.0";
|
||||
|
||||
const app = document.querySelector<HTMLElement>("#app");
|
||||
if (!app) throw new Error("index.html has no #app");
|
||||
|
||||
const apps = model.slice(1, -1);
|
||||
const parts = new Map<string, SVGGElement[]>();
|
||||
const blocks: SVGGElement[] = [];
|
||||
let beat = 1;
|
||||
for (const part of model) {
|
||||
const own = part.blocks.map((spec, i) => {
|
||||
const g = block(spec);
|
||||
g.style.setProperty("--land", `${(beat * BEAT + i * STEP).toFixed(3)}s`);
|
||||
return g;
|
||||
});
|
||||
if (apps.includes(part)) parts.set(part.name, own);
|
||||
blocks.push(...own);
|
||||
beat += Math.ceil(part.blocks.length / 4);
|
||||
}
|
||||
const built = model.map((part) => part.blocks.map(block));
|
||||
grow(built);
|
||||
const parts = new Map(apps.map((p) => [p.name, built[model.indexOf(p)]]));
|
||||
|
||||
const hero = document.createElement("header");
|
||||
hero.className = "hero";
|
||||
const title = document.createElement("h1");
|
||||
title.textContent = "Ely Office";
|
||||
const host = document.createElement("div");
|
||||
host.className = "model";
|
||||
const svg = stage(blocks);
|
||||
svg.classList.add("grow");
|
||||
const svg = stage(built.flat());
|
||||
svg.classList.add("grow", "seen");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
host.append(svg);
|
||||
app.append(title, host);
|
||||
hero.append(title, said("p", "tagline"), said("p", "platforms"), host);
|
||||
touch(host, svg, parts);
|
||||
|
||||
const sections = regions();
|
||||
|
||||
const footer = document.createElement("footer");
|
||||
footer.id = "download";
|
||||
const version = document.createElement("h2");
|
||||
version.textContent = "Ely Office 0.1.0";
|
||||
const list = document.createElement("ul");
|
||||
for (const p of apps) {
|
||||
const a = document.createElement("a");
|
||||
a.href = `${RELEASE}/${p.name}-0.1.0-arm64.dmg`;
|
||||
a.textContent = p.name;
|
||||
const li = document.createElement("li");
|
||||
li.append(a);
|
||||
list.append(li);
|
||||
}
|
||||
const source = said("a", "source");
|
||||
source.href = `${RELEASE}/ElyOffice-0.1.0-source.tar.gz`;
|
||||
const li = document.createElement("li");
|
||||
li.append(source);
|
||||
list.append(li);
|
||||
footer.append(version, said("p", "platforms"), said("p", "needs"), said("p", "unsigned"), list);
|
||||
|
||||
const toggle = said("button", "other");
|
||||
toggle.className = "lang";
|
||||
let lang: Lang = navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en";
|
||||
toggle.addEventListener("click", () => {
|
||||
lang = lang === "en" ? "zh" : "en";
|
||||
speak(lang);
|
||||
});
|
||||
|
||||
const main = document.createElement("main");
|
||||
main.append(...sections);
|
||||
app.append(toggle, hero, main, footer);
|
||||
speak(lang);
|
||||
reveal(sections);
|
||||
@@ -0,0 +1,120 @@
|
||||
// One region per app, then the three props.
|
||||
import { block, screen, stage, type Block } from "./iso.ts";
|
||||
import { grow } from "./beat.ts";
|
||||
import { model, type Part } from "./model.ts";
|
||||
import { said, type Key } from "./text.ts";
|
||||
|
||||
const RELEASE = "https://github.com/ZacharyZhang-NY/ElyOffice/releases/download/v0.1.0";
|
||||
|
||||
const PAPER = "#f4f4f2";
|
||||
const DESK = "#e6e6e2";
|
||||
const SCREEN = "#3a3a3c";
|
||||
const SHOT = [1440, 810];
|
||||
|
||||
/** A part's blocks moved to corner (x, y). */
|
||||
function moved(part: Part, x: number, y: number): Required<Block>[] {
|
||||
const left = Math.min(...part.blocks.map((b) => b.x));
|
||||
const back = Math.min(...part.blocks.map((b) => b.y));
|
||||
const floor = Math.min(...part.blocks.map((b) => b.z));
|
||||
const snap = (v: number) => Math.round(v * 1000) / 1000;
|
||||
return part.blocks.map((b) => ({ ...b, x: snap(b.x - left + x), y: snap(b.y - back + y), z: snap(b.z - floor + 0.4) }));
|
||||
}
|
||||
|
||||
/** A plinth, the app's screen, and its building. */
|
||||
function scene(part: Part): SVGSVGElement {
|
||||
const plinth = block({ x: 0, y: 0, w: 11.6, d: 5, h: 0.4, tone: DESK });
|
||||
const slab = block({ x: 0.4, y: 1.2, z: 0.4, w: 7.2, d: 0.25, h: 4.05, tone: SCREEN });
|
||||
screen(slab, `/shots/${part.name.slice(3).toLowerCase()}.jpg`, SHOT[0], SHOT[1]);
|
||||
const house = moved(part, 8.4, 1.2).map(block);
|
||||
grow([[plinth], [slab], house]);
|
||||
const svg = stage([plinth, slab, ...house]);
|
||||
svg.classList.add("grow");
|
||||
svg.setAttribute("role", "img");
|
||||
svg.dataset.shot = part.name;
|
||||
return svg;
|
||||
}
|
||||
|
||||
const t = (x: number, y: number, z: number, w: number, d: number, h: number, tone = PAPER) =>
|
||||
block({ x, y, z, w, d, h, tone });
|
||||
|
||||
/** Offline: a plug pulled from its socket. */
|
||||
const cable = () => [
|
||||
t(0, 0, 0, 7, 3, 0.3, DESK),
|
||||
t(0.4, 1.3, 0.3, 2.6, 0.4, 0.4),
|
||||
t(3, 1.15, 0.3, 0.9, 0.7, 0.7),
|
||||
t(5, 0.9, 0.3, 1.4, 1.2, 1.2),
|
||||
];
|
||||
|
||||
/** Open source: a box with its lid laid open. */
|
||||
const box = () => [
|
||||
t(0, 0, 0, 7, 4, 0.3, DESK),
|
||||
t(1, 0.6, 0.3, 3, 0.2, 1.6),
|
||||
t(1, 0.8, 0.3, 0.2, 2.2, 1.6),
|
||||
t(3.8, 0.8, 0.3, 0.2, 2.2, 1.6),
|
||||
t(1.2, 0.8, 0.3, 2.6, 2.2, 0.2),
|
||||
t(1, 3, 0.3, 3, 0.2, 1.6),
|
||||
t(4.3, 0.6, 0.3, 2.2, 2.6, 0.12),
|
||||
];
|
||||
|
||||
/** Local files: a folder holding one sheet. */
|
||||
const folder = () => [
|
||||
t(0, 0, 0, 7, 3, 0.3, DESK),
|
||||
t(1, 1, 0.3, 4.6, 0.2, 2.6),
|
||||
t(1, 1, 2.9, 1.6, 0.2, 0.4),
|
||||
t(1.4, 1.2, 0.3, 3.8, 0.1, 2.3, "#3d7fe5"),
|
||||
t(1, 1.3, 0.3, 4.6, 0.2, 2),
|
||||
];
|
||||
|
||||
/** A prop with its line. */
|
||||
function prop(blocks: SVGGElement[], line: Key): HTMLElement {
|
||||
grow(blocks.map((b) => [b]));
|
||||
const svg = stage(blocks);
|
||||
svg.classList.add("grow");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
const p = said("p", line);
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "prop";
|
||||
cell.append(svg, p);
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** The app regions and the props row. */
|
||||
export function regions(): HTMLElement[] {
|
||||
const apps = model.slice(1, -1).map((part) => {
|
||||
const section = document.createElement("section");
|
||||
section.id = part.name.toLowerCase();
|
||||
section.className = "region";
|
||||
const h2 = document.createElement("h2");
|
||||
h2.textContent = part.name;
|
||||
const get = said("a", "download");
|
||||
get.className = "get";
|
||||
get.href = `${RELEASE}/${part.name}-0.1.0-arm64.dmg`;
|
||||
const text = document.createElement("div");
|
||||
text.append(h2, said("p", part.name as Key), get);
|
||||
section.append(scene(part), text);
|
||||
return section;
|
||||
});
|
||||
const props = document.createElement("section");
|
||||
props.className = "props";
|
||||
props.append(
|
||||
prop(cable(), "offline"),
|
||||
prop(box(), "open"),
|
||||
prop(folder(), "local"),
|
||||
);
|
||||
return [...apps, props];
|
||||
}
|
||||
|
||||
/** Starts each model's growth when it scrolls near. */
|
||||
export function reveal(sections: HTMLElement[]): void {
|
||||
const seen = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
for (const svg of e.target.querySelectorAll(".stage")) svg.classList.add("seen");
|
||||
seen.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -20% 0px" },
|
||||
);
|
||||
for (const s of sections) seen.observe(s);
|
||||
}
|
||||
+185
-20
@@ -3,30 +3,63 @@
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", sans-serif;
|
||||
color: #1d1d1f;
|
||||
background: #fbfbfa;
|
||||
--quiet: #6e6e73;
|
||||
--accent: #3d7fe5;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 6rem;
|
||||
padding: 9rem 1.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1 {
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(3rem, 8vw, 6rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(3rem, 9vw, 6.5rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: clamp(1.0625rem, 1.6vw, 1.3125rem);
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: var(--quiet);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 10rem 1.5rem 9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero .model {
|
||||
margin-top: 6rem;
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
@@ -37,13 +70,143 @@ h1 {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.region {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr;
|
||||
align-items: center;
|
||||
gap: 6rem;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 9rem 1.5rem;
|
||||
}
|
||||
|
||||
.region > svg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.region:nth-child(even) {
|
||||
grid-template-columns: 1fr 1.6fr;
|
||||
}
|
||||
|
||||
.region:nth-child(even) > svg {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.region div {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.get {
|
||||
font-size: 1.0625rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.region:hover .get,
|
||||
.region:focus-within .get {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.get {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.props {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4rem;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 9rem 1.5rem;
|
||||
}
|
||||
|
||||
.prop {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 1rem;
|
||||
padding: 9rem 1.5rem 12rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1rem 2.5rem;
|
||||
margin: 2rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
|
||||
.lang {
|
||||
position: fixed;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 300;
|
||||
color: var(--quiet);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.lang:hover,
|
||||
.lang:focus-visible,
|
||||
body:has(.hero:hover) .lang {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.lang {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.region,
|
||||
.props {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3rem;
|
||||
padding: 6rem 1.5rem;
|
||||
}
|
||||
|
||||
.region:nth-child(even) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.region:nth-child(even) > svg {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 7rem 1.5rem 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.caption {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
translate: -50% calc(-100% - 0.75rem);
|
||||
translate: -50% -100%;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 300;
|
||||
color: #6e6e73;
|
||||
color: var(--quiet);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
@@ -54,12 +217,6 @@ h1 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.caption {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keyboard and reader links; focus shows on the model. */
|
||||
.doors a {
|
||||
position: absolute;
|
||||
@@ -69,3 +226,11 @@ h1 {
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.caption,
|
||||
.get,
|
||||
.lang {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Every word on the page, in both languages.
|
||||
export type Lang = "en" | "zh";
|
||||
|
||||
const en = {
|
||||
tagline: "Six desktop apps. They work offline.",
|
||||
ElyDoc: "Letters, reports, books. Opens and saves Word files.",
|
||||
ElyTable: "Formulas, filters, charts. Opens and saves Excel files.",
|
||||
ElyShow: "Slides, animation, a presenter view. Opens PowerPoint files.",
|
||||
ElyPDF: "Read, mark up, sign, fill forms. Finds text in scans.",
|
||||
ElyTree: "Stage lines, commit, read the graph. Git without a terminal.",
|
||||
ElyMark: "Markdown as you read it. Math and diagrams in place.",
|
||||
offline: "Works with the network off.",
|
||||
open: "Open source, under the AGPL.",
|
||||
local: "Your files stay in your folders.",
|
||||
download: "Download",
|
||||
platforms: "Out now for macOS. Windows and Linux are coming soon.",
|
||||
needs: "macOS 14 or later, Apple silicon.",
|
||||
unsigned: "Not notarized. The first time, Control-click the app and choose Open.",
|
||||
source: "Source code",
|
||||
shot: "screenshot",
|
||||
other: "中文",
|
||||
};
|
||||
|
||||
const zh: typeof en = {
|
||||
tagline: "六个桌面应用,断网也能用。",
|
||||
ElyDoc: "信件、报告、书稿。打开并保存 Word 文件。",
|
||||
ElyTable: "公式、筛选、图表。打开并保存 Excel 文件。",
|
||||
ElyShow: "幻灯片、动画、演讲者视图。打开 PowerPoint 文件。",
|
||||
ElyPDF: "阅读、批注、签名、填表。扫描件里也能查字。",
|
||||
ElyTree: "按行暂存、提交、看提交图。不用终端的 Git。",
|
||||
ElyMark: "写的就是看到的 Markdown。公式与图表就地显示。",
|
||||
offline: "断网照常工作。",
|
||||
open: "开源,AGPL 许可。",
|
||||
local: "文件留在你自己的文件夹里。",
|
||||
download: "下载",
|
||||
platforms: "macOS 版已发布。Windows 与 Linux 版即将推出。",
|
||||
needs: "macOS 14 或更新,Apple 芯片。",
|
||||
unsigned: "未经公证。第一次打开时,按住 Control 点按应用,选“打开”。",
|
||||
source: "源代码",
|
||||
shot: "截图",
|
||||
other: "English",
|
||||
};
|
||||
|
||||
export type Key = keyof typeof en;
|
||||
export const TEXT: Record<Lang, typeof en> = { en, zh };
|
||||
|
||||
/** An element whose text follows the language. */
|
||||
export function said<K extends keyof HTMLElementTagNameMap>(tag: K, key: Key): HTMLElementTagNameMap[K] {
|
||||
const el = document.createElement(tag);
|
||||
el.dataset.t = key;
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Writes every keyed text in `lang`. */
|
||||
export function speak(lang: Lang): void {
|
||||
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
|
||||
for (const el of document.querySelectorAll<HTMLElement>("[data-t]")) {
|
||||
el.textContent = TEXT[lang][el.dataset.t as Key];
|
||||
}
|
||||
for (const el of document.querySelectorAll<SVGElement>("[data-shot]")) {
|
||||
el.setAttribute("aria-label", `${el.dataset.shot} ${TEXT[lang].shot}`);
|
||||
}
|
||||
}
|
||||
+38
-28
@@ -10,12 +10,13 @@ export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map<string,
|
||||
nav.className = "doors";
|
||||
host.append(nav);
|
||||
|
||||
const owner = new Map([...parts].flatMap(([name, blocks]) => blocks.map((b) => [b, name] as const)));
|
||||
const hits = still(svg, owner);
|
||||
|
||||
for (const [name, blocks] of parts) {
|
||||
const target = `#${name.toLowerCase()}`;
|
||||
const state = { hover: false, focus: false, press: false };
|
||||
for (const b of blocks) b.dataset.part = name;
|
||||
const hit = area(blocks, name);
|
||||
svg.append(hit);
|
||||
const hit = hits.filter((h) => h.dataset.part === name);
|
||||
const caption = document.createElement("p");
|
||||
caption.className = "caption";
|
||||
caption.textContent = name;
|
||||
@@ -29,19 +30,22 @@ export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map<string,
|
||||
b.classList.toggle("pressed", up && state.press);
|
||||
}
|
||||
caption.classList.toggle("shown", up);
|
||||
if (up) place(caption, host, hit);
|
||||
if (up) place(caption, host, svg, blocks);
|
||||
};
|
||||
const set = (change: Partial<typeof state>) => {
|
||||
Object.assign(state, change);
|
||||
show();
|
||||
};
|
||||
|
||||
hit.addEventListener("pointerenter", () => set({ hover: true }));
|
||||
hit.addEventListener("pointerleave", () => set({ hover: false, press: false }));
|
||||
hit.addEventListener("pointerdown", (e) => e.button === 0 && set({ press: true }));
|
||||
hit.addEventListener("pointerup", () => set({ press: false }));
|
||||
hit.addEventListener("pointercancel", () => set({ press: false }));
|
||||
hit.addEventListener("click", () => (location.hash = target));
|
||||
const own = (node: EventTarget | null) => node instanceof SVGElement && node.dataset.part === name;
|
||||
for (const h of hit) {
|
||||
h.addEventListener("pointerenter", () => set({ hover: true }));
|
||||
h.addEventListener("pointerleave", (e) => own(e.relatedTarget) || set({ hover: false, press: false }));
|
||||
h.addEventListener("pointerdown", (e) => e.button === 0 && set({ press: true }));
|
||||
h.addEventListener("pointerup", () => set({ press: false }));
|
||||
h.addEventListener("pointercancel", () => set({ press: false }));
|
||||
h.addEventListener("click", () => (location.hash = target));
|
||||
}
|
||||
|
||||
const door = document.createElement("a");
|
||||
door.href = target;
|
||||
@@ -54,28 +58,34 @@ export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map<string,
|
||||
}
|
||||
}
|
||||
|
||||
/** A still hit area over the building, resting and lifted. */
|
||||
function area(blocks: SVGGElement[], name: string): SVGGElement {
|
||||
const hit = document.createElementNS(SVG, "g");
|
||||
hit.setAttribute("class", "hit");
|
||||
hit.dataset.part = name;
|
||||
for (const b of blocks) {
|
||||
/** Resting outlines over the model, layered as drawn. */
|
||||
function still(svg: SVGSVGElement, owner: Map<SVGGElement, string>): SVGPolygonElement[] {
|
||||
const layer = document.createElementNS(SVG, "g");
|
||||
layer.setAttribute("class", "hits");
|
||||
const hits: SVGPolygonElement[] = [];
|
||||
for (const b of svg.querySelectorAll<SVGGElement>(".block")) {
|
||||
const outline = b.querySelector(".outline");
|
||||
if (!outline) throw new Error("block has no outline");
|
||||
for (const dy of [0, -UNIT]) {
|
||||
const face = outline.cloneNode() as SVGPolygonElement;
|
||||
face.setAttribute("class", "hit-face");
|
||||
face.setAttribute("transform", `translate(0 ${dy})`);
|
||||
hit.append(face);
|
||||
}
|
||||
const name = owner.get(b);
|
||||
b.dataset.part = name ?? "";
|
||||
const face = outline.cloneNode() as SVGPolygonElement;
|
||||
face.setAttribute("class", name ? "hit-face" : "hit-face ground");
|
||||
face.dataset.part = name ?? "";
|
||||
layer.append(face);
|
||||
if (name) hits.push(face);
|
||||
}
|
||||
return hit;
|
||||
svg.append(layer);
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** Puts the caption above the lifted building. */
|
||||
function place(caption: HTMLElement, host: HTMLElement, hit: SVGGElement) {
|
||||
/** Puts the caption one step above the building's top. */
|
||||
function place(caption: HTMLElement, host: HTMLElement, svg: SVGSVGElement, blocks: SVGGElement[]) {
|
||||
const origin = host.getBoundingClientRect();
|
||||
const r = hit.getBoundingClientRect();
|
||||
caption.style.left = `${r.left + r.width / 2 - origin.left}px`;
|
||||
caption.style.top = `${r.top - origin.top}px`;
|
||||
const scale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
|
||||
const rects = blocks.map((b) => b.querySelector(".outline")!.getBoundingClientRect());
|
||||
const left = Math.min(...rects.map((r) => r.left));
|
||||
const right = Math.max(...rects.map((r) => r.right));
|
||||
const top = Math.min(...rects.map((r) => r.top));
|
||||
caption.style.left = `${(left + right) / 2 - origin.left}px`;
|
||||
caption.style.top = `${top - origin.top - UNIT * scale}px`;
|
||||
}
|
||||
Reference in new issue
Block a user