@a2f0/skyline 0.2.35 → 0.2.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/NOTICE.md CHANGED
@@ -7,6 +7,11 @@ The self-contained viewer includes Three.js r186 under the MIT license. Its full
7
7
  notice is packaged at `site/vendor/THREE-LICENSE.txt`. Imported scene and building
8
8
  modules use the consuming application's `three` dependency instead.
9
9
 
10
+ The standalone site's assets also include `@a2f0/ct-client` from
11
+ [a2f0/ct](https://github.com/a2f0/ct), copied to `site/vendor/ct-client.js`.
12
+ Its package metadata uses `UNLICENSED`; this release does not grant a license
13
+ for that code. Importing or mounting the reusable viewer does not initialize it.
14
+
10
15
  Mapped building footprints, building parts, and streets contain data from
11
16
  © OpenStreetMap contributors, under the Open Database License (ODbL) 1.0:
12
17
 
package/docs/package.md CHANGED
@@ -87,6 +87,11 @@ it does not install Skyline's development dependencies or run dependency
87
87
  lifecycle scripts. Keep `trustedDependencies` unchanged. Run `bun install` after
88
88
  adding the script. Both the published tarball and this GitHub build expose the
89
89
  same entrypoints, declarations, and complete viewer assets.
90
+ The declared `@a2f0/ct-client` dependency supplies the standalone site's pinned
91
+ analytics asset during this build. Package imports and viewer mounts do not
92
+ initialize tracking; only the standalone production site's page bootstraps do.
93
+ Npm consumers also receive this dependency, although their tarball already
94
+ contains the built asset, so both install methods keep the same dependency graph.
90
95
 
91
96
  ## Copy the viewer assets
92
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/skyline",
3
- "version": "0.2.35",
3
+ "version": "0.2.37",
4
4
  "description": "Interactive Chicago skyline viewer and reusable Three.js building models",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -52,6 +52,7 @@
52
52
  }
53
53
  },
54
54
  "dependencies": {
55
+ "@a2f0/ct-client": "0.1.0",
55
56
  "@types/three": "0.186.0"
56
57
  },
57
58
  "scripts": {
@@ -74,13 +75,13 @@
74
75
  "verify:deploy": "bun scripts/verify-deploy.ts"
75
76
  },
76
77
  "devDependencies": {
77
- "@a2f0/agent-tool": "0.1.15",
78
+ "@a2f0/agent-tool": "0.1.17",
78
79
  "@tsconfig/strictest": "^2.0.8",
79
80
  "@types/bun": "^1.4.3",
80
81
  "esbuild": "0.28.2",
81
82
  "three": "0.186.1",
82
83
  "playwright": "1.64.1",
83
84
  "typescript": "^7.0.2",
84
- "wrangler": "4.151.1"
85
+ "wrangler": "4.151.3"
85
86
  }
86
87
  }
@@ -10,6 +10,7 @@
10
10
  <link rel="stylesheet" href="study.css" />
11
11
  <!-- Framed by another page, the page leaves its links to the frame's own controls. -->
12
12
  <script>if (window.frameElement) document.documentElement.classList.add("framed");</script>
13
+ <script type="module" src="click-tracking-page.js"></script>
13
14
  </head>
14
15
  <body>
15
16
  <!-- The viewer's panel renders .detail-root in a shadow root of its own (skyline-3d.ts). -->
@@ -34,15 +35,15 @@
34
35
  </div>
35
36
  <div class="toolbar" aria-label="Building detail controls">
36
37
  <div class="button-group" role="group" aria-label="Camera views">
37
- <button type="button" data-view="front">front</button>
38
- <button type="button" data-view="quarter" aria-pressed="true">three-quarter</button>
39
- <button type="button" data-view="side">side</button>
40
- <button type="button" id="reset">reset view</button>
38
+ <button type="button" data-view="front" data-ct="detail.view.front">front</button>
39
+ <button type="button" data-view="quarter" data-ct="detail.view.quarter" aria-pressed="true">three-quarter</button>
40
+ <button type="button" data-view="side" data-ct="detail.view.side">side</button>
41
+ <button type="button" id="reset" data-ct="detail.reset">reset view</button>
41
42
  </div>
42
43
  <div class="button-group" role="group" aria-label="Model display">
43
- <button type="button" id="wireframe" aria-pressed="false">wireframe</button>
44
- <button type="button" id="colour" aria-pressed="false" title="Show the building in sunny-day colour">colour</button>
45
- <button type="button" id="turntable" aria-pressed="false" aria-describedby="motion-status">turntable</button>
44
+ <button type="button" id="wireframe" data-ct="detail.wireframe" aria-pressed="false">wireframe</button>
45
+ <button type="button" id="colour" data-ct="detail.colour" aria-pressed="false" title="Show the building in sunny-day colour">colour</button>
46
+ <button type="button" id="turntable" data-ct="detail.turntable" aria-pressed="false" aria-describedby="motion-status">turntable</button>
46
47
  </div>
47
48
  </div>
48
49
  </div>
@@ -8,6 +8,7 @@
8
8
  <link rel="icon" href="data:," />
9
9
  <title>Crain Communications Building — 3D Study</title>
10
10
  <link rel="stylesheet" href="study.css" />
11
+ <script type="module" src="click-tracking-page.js"></script>
11
12
  </head>
12
13
  <body>
13
14
  <header>
@@ -0,0 +1,23 @@
1
+ import { createTracker } from "./vendor/ct-client.js";
2
+ // Only the standalone site's pages load this entry. Package mounts never import it.
3
+ // Use fixed routes: building IDs, embed settings and other URL values stay private.
4
+ const routes = {
5
+ "/": "/",
6
+ "/index.html": "/",
7
+ "/skyline-3d": "/skyline-3d",
8
+ "/skyline-3d.html": "/skyline-3d",
9
+ "/building-detail": "/building-detail",
10
+ "/building-detail.html": "/building-detail",
11
+ "/skyline-study": "/skyline-study",
12
+ "/skyline-study.html": "/skyline-study",
13
+ "/building-study": "/building-study",
14
+ "/building-study.html": "/building-study",
15
+ "/skyline-webgl": "/skyline-webgl",
16
+ "/skyline-webgl.html": "/skyline-webgl",
17
+ "/skyline-animated.svg": "/skyline-animated.svg",
18
+ };
19
+ const route = routes[location.pathname];
20
+ if (location.origin === "https://skyline.devopsrockstars.com" && route) {
21
+ const tracker = createTracker({ site: "skyline", capture: "all", page: () => route });
22
+ tracker.init();
23
+ }
package/site/index.html CHANGED
@@ -49,6 +49,7 @@
49
49
  document.querySelector('meta[name="theme-color"]').content = light ? "#f5f5f5" : "#000000";
50
50
  }
51
51
  </script>
52
+ <script type="module" src="click-tracking-page.js"></script>
52
53
  </head>
53
54
  <body>
54
55
  <!-- The viewer renders into a shadow root inside this element, as in a host's page. -->
@@ -8,6 +8,7 @@
8
8
  <link rel="icon" href="data:," />
9
9
  <link rel="stylesheet" href="study.css" />
10
10
  <title>3D Chicago Skyline</title>
11
+ <script type="module" src="click-tracking-page.js"></script>
11
12
  </head>
12
13
  <body class="skyline-3d">
13
14
  <main class="viewport" id="viewport">
@@ -32,35 +33,35 @@
32
33
  <p class="visually-hidden" id="celebration-announcement" role="status"></p>
33
34
  <noscript><style>#loading { display: none; }</style><p class="loading">Enable JavaScript to explore the 3D skyline.</p></noscript>
34
35
  <section class="detail-panel" id="building-detail" role="dialog" aria-label="Building detail" hidden>
35
- <button type="button" class="detail-close" id="detail-close" aria-label="Close building detail" title="Close (Esc)">×</button>
36
+ <button type="button" class="detail-close" id="detail-close" data-ct="skyline.detail-close" aria-label="Close building detail" title="Close (Esc)">×</button>
36
37
  </section>
37
38
  </main>
38
39
  <footer class="control-bar" aria-label="3D skyline controls">
39
40
  <!-- Chicago's six-pointed star, after devopsrockstars' white-star-only.svg, drawn in
40
41
  the bar's muted grey: points 12 units out, the notches between them 4.66. -->
41
- <button type="button" class="menu-toggle" id="menu-toggle" aria-expanded="false" aria-controls="camera-views model-display scene-notes" aria-label="Skyline controls" title="Show the controls">
42
+ <button type="button" class="menu-toggle" id="menu-toggle" data-ct="skyline.menu-toggle" aria-expanded="false" aria-controls="camera-views model-display scene-notes" aria-label="Skyline controls" title="Show the controls">
42
43
  <svg viewBox="-12 -12 24 24" aria-hidden="true" focusable="false"><path d="M0-12 2.33-4.04 10.39-6 4.66 0 10.39 6 2.33 4.04 0 12-2.33 4.04-10.39 6-4.66 0-10.39-6-2.33-4.04Z" /></svg>
43
44
  </button>
44
45
  <div class="button-group camera-views" id="camera-views" role="group" aria-label="Camera views">
45
- <button type="button" data-view="skyline" aria-pressed="true">skyline view</button>
46
- <button type="button" data-view="quarter" aria-pressed="false">three-quarter</button>
47
- <button type="button" data-view="side" aria-pressed="false">side</button>
48
- <button type="button" data-view="top" aria-pressed="false">ground plan ↓</button>
49
- <button type="button" data-view="heights" aria-pressed="false">height comparison</button>
50
- <button type="button" id="reset">reset view</button>
46
+ <button type="button" data-view="skyline" data-ct="skyline.view.skyline" aria-pressed="true">skyline view</button>
47
+ <button type="button" data-view="quarter" data-ct="skyline.view.quarter" aria-pressed="false">three-quarter</button>
48
+ <button type="button" data-view="side" data-ct="skyline.view.side" aria-pressed="false">side</button>
49
+ <button type="button" data-view="top" data-ct="skyline.view.top" aria-pressed="false">ground plan ↓</button>
50
+ <button type="button" data-view="heights" data-ct="skyline.view.heights" aria-pressed="false">height comparison</button>
51
+ <button type="button" id="reset" data-ct="skyline.reset">reset view</button>
51
52
  </div>
52
53
  <div class="button-group model-display" id="model-display" role="group" aria-label="Model display">
53
- <button type="button" id="streets" aria-pressed="true">streets</button>
54
- <button type="button" id="footprints" aria-pressed="false">footprints</button>
55
- <button type="button" id="wireframe" aria-pressed="false">wireframe</button>
56
- <button type="button" id="colour" aria-pressed="false" title="Colour trial: a sunny day, each measured building in its photographed colours; the rest stay grey">colour</button>
57
- <button type="button" id="turntable" aria-pressed="false" aria-describedby="motion-status">turntable</button>
58
- <button type="button" id="show-original" title="Compare with the original skyline SVG" hidden>show original</button>
54
+ <button type="button" id="streets" data-ct="skyline.streets" aria-pressed="true">streets</button>
55
+ <button type="button" id="footprints" data-ct="skyline.footprints" aria-pressed="false">footprints</button>
56
+ <button type="button" id="wireframe" data-ct="skyline.wireframe" aria-pressed="false">wireframe</button>
57
+ <button type="button" id="colour" data-ct="skyline.colour" aria-pressed="false" title="Colour trial: a sunny day, each measured building in its photographed colours; the rest stay grey">colour</button>
58
+ <button type="button" id="turntable" data-ct="skyline.turntable" aria-pressed="false" aria-describedby="motion-status">turntable</button>
59
+ <button type="button" id="show-original" data-ct="skyline.show-original" title="Compare with the original skyline SVG" hidden>show original</button>
59
60
  <div class="time-controls">
60
61
  <label for="time-of-day"><span id="time-caption">Chicago time</span> <span id="time-date"></span></label>
61
62
  <input id="time-of-day" type="range" min="0" max="1439" step="1" value="0" aria-labelledby="time-caption" />
62
63
  <output id="time-value" for="time-of-day" aria-live="off">original night</output>
63
- <button type="button" id="reset-light" disabled title="Restore the original lighting preset">reset light</button>
64
+ <button type="button" id="reset-light" data-ct="skyline.reset-light" disabled title="Restore the original lighting preset">reset light</button>
64
65
  </div>
65
66
  <div class="celebration-controls" id="celebration-controls" role="group" aria-label="Celebratory window lighting">
66
67
  <span class="celebration-caption">lights</span>
@@ -107,7 +108,7 @@
107
108
  <div class="context-menu" id="building-menu" hidden>
108
109
  <p class="context-menu-title" id="building-menu-title"></p>
109
110
  <div role="menu" aria-labelledby="building-menu-title">
110
- <a role="menuitem" id="building-detail-link" href="building-detail.html">building detail</a>
111
+ <a role="menuitem" id="building-detail-link" data-ct="skyline.building-detail-link" href="building-detail.html">building detail</a>
111
112
  <div id="building-lighting-menu" role="group" aria-label="Celebratory window lighting" hidden></div>
112
113
  </div>
113
114
  </div>
@@ -18684,6 +18684,8 @@
18684
18684
  font-weight="600">Building name</text>
18685
18685
  </g>
18686
18686
  <script><![CDATA[
18687
+ // Analytics is optional and must not prevent the drawing from loading.
18688
+ import("./click-tracking-page.js").catch(() => {});
18687
18689
  (() => {
18688
18690
  const svg = document.documentElement;
18689
18691
  const tooltip = document.querySelector("#building-tooltip");
@@ -8,6 +8,7 @@
8
8
  <link rel="icon" href="data:," />
9
9
  <link rel="stylesheet" href="study.css" />
10
10
  <title>Thirty-four on the skyline — Skyline Study</title>
11
+ <script type="module" src="click-tracking-page.js"></script>
11
12
  </head>
12
13
  <body class="skyline-study">
13
14
  <header>
@@ -266,16 +266,16 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
266
266
  function createToolbar() {
267
267
  const nav = element(document, "nav", { className: "controls" }, { "aria-label": "Skyline controls" });
268
268
  const button = (id, text, attributes = {}) => element(document, "button", { type: "button", id, textContent: text }, { "aria-pressed": "false", ...attributes });
269
- const fullscreenButton = button("fullscreen", "fullscreen");
269
+ const fullscreenButton = button("fullscreen", "fullscreen", { "data-ct": "viewer.fullscreen" });
270
270
  fullscreenButton.hidden = !document.fullscreenEnabled;
271
271
  fullscreenButton.addEventListener("click", () => void toggleFullscreen(), listening);
272
272
  nav.append(fullscreenButton);
273
273
  for (const [name, toggle] of Object.entries(toggles)) {
274
- const toggleButton = button(toggle.id, toggle.label);
274
+ const toggleButton = button(toggle.id, toggle.label, { "data-ct": `viewer.mode.${name}` });
275
275
  toggleButton.addEventListener("click", () => showMode(mode === name ? "3d" : name), listening);
276
276
  nav.append(toggleButton);
277
277
  }
278
- nav.append(element(document, "a", { href: new URL("building-study.html", assets).href, textContent: "3d building" }), element(document, "a", { href: new URL("skyline-study.html", assets).href, textContent: "skyline study" }));
278
+ nav.append(element(document, "a", { href: new URL("building-study.html", assets).href, textContent: "3d building" }, { "data-ct": "viewer.building-study" }), element(document, "a", { href: new URL("skyline-study.html", assets).href, textContent: "skyline study" }, { "data-ct": "viewer.skyline-study" }));
279
279
  const debugButton = button("debug-motion", "debug motion", { "aria-describedby": "stars-motion-status" });
280
280
  const motionStatus = element(document, "p", { className: "motion-status", id: "stars-motion-status" }, { role: "status" });
281
281
  nav.append(debugButton, motionStatus);
@@ -74,6 +74,7 @@
74
74
  display: none !important;
75
75
  }
76
76
  </style>
77
+ <script type="module" src="click-tracking-page.js"></script>
77
78
  </head>
78
79
  <body>
79
80
  <canvas id="skyline" role="img" aria-label="Interactive WebGL rendering of the Chicago skyline"></canvas>
@@ -0,0 +1,212 @@
1
+ // src/privacy.ts
2
+ var OPT_OUT_KEY = "ct:opt-out";
3
+ var OPT_OUT_EVENT = "ct:opt-out-change";
4
+ var EXTENSION_ATTRIBUTE = "data-ct-disabled";
5
+ function optedOut() {
6
+ try {
7
+ return navigator.webdriver === true || navigator.doNotTrack === "1" || navigator.globalPrivacyControl === true || document.documentElement?.hasAttribute(EXTENSION_ATTRIBUTE) === true || localStorage.getItem(OPT_OUT_KEY) === "1";
8
+ } catch {
9
+ return true;
10
+ }
11
+ }
12
+ function safePage(value) {
13
+ const path = value.split(/[?#]/, 1)[0] ?? "/";
14
+ return /^\/[a-zA-Z0-9/:_.-]{0,199}$/.test(path) ? path : "/";
15
+ }
16
+ function clickTarget(event, capture) {
17
+ const path = event.composedPath();
18
+ const elements = path.filter((item) => item instanceof Element);
19
+ if (elements.some((element) => element.matches("[data-ct-ignore], input, textarea, select, [contenteditable]:not([contenteditable='false'])")))
20
+ return null;
21
+ const selector = capture === "marked" ? "[data-ct]" : "[data-ct], a, button, [role='button'], [role='link']";
22
+ const target = elements.find((element) => element.matches(selector)) ?? (capture === "all" ? elements[0] : undefined);
23
+ if (!target)
24
+ return null;
25
+ const label = target.getAttribute("data-ct");
26
+ const tag = target.tagName.toLowerCase();
27
+ return {
28
+ target: /^[a-z][a-z0-9-]{0,31}$/.test(tag) ? tag : "unknown",
29
+ label: label && /^[a-zA-Z0-9_.:-]{1,80}$/.test(label) ? label : null
30
+ };
31
+ }
32
+
33
+ // src/index.ts
34
+ function createTracker(options) {
35
+ const batchSize = bounded(options.batchSize, 20, 1, 50);
36
+ const interval = bounded(options.flushIntervalMs, 5000, 100, 60000);
37
+ const capacity = bounded(options.maxQueueSize, 500, batchSize, 1000);
38
+ const endpoint = options.endpoint ?? "https://ct.a2f0.net/v1/clicks";
39
+ let initialized = false;
40
+ let disabled = false;
41
+ let queue = [];
42
+ let timer;
43
+ let request;
44
+ let inFlight;
45
+ let failures = 0;
46
+ let generation = 0;
47
+ function clear() {
48
+ generation++;
49
+ failures = 0;
50
+ queue = [];
51
+ if (timer)
52
+ clearTimeout(timer);
53
+ timer = undefined;
54
+ request?.abort();
55
+ }
56
+ function blocked() {
57
+ if (disabled || optedOut()) {
58
+ clear();
59
+ return true;
60
+ }
61
+ return false;
62
+ }
63
+ function schedule() {
64
+ if (!initialized || !queue.length || timer)
65
+ return;
66
+ const delay = failures ? Math.min(60000, interval * 2 ** Math.min(failures, 5)) : interval;
67
+ timer = setTimeout(() => {
68
+ timer = undefined;
69
+ flush();
70
+ }, delay);
71
+ }
72
+ async function send() {
73
+ const currentGeneration = generation;
74
+ const batch = queue.slice(0, batchSize);
75
+ const controller = new AbortController;
76
+ request = controller;
77
+ const timeout = setTimeout(() => controller.abort(), 1e4);
78
+ try {
79
+ const response = await fetch(endpoint, {
80
+ method: "POST",
81
+ headers: { "Content-Type": "text/plain;charset=UTF-8" },
82
+ body: JSON.stringify({ site: options.site, events: batch }),
83
+ credentials: "omit",
84
+ referrerPolicy: "no-referrer",
85
+ keepalive: true,
86
+ signal: controller.signal
87
+ });
88
+ if (currentGeneration !== generation)
89
+ return;
90
+ if (response.ok || response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) {
91
+ const ids = new Set(batch.map((event) => event.id));
92
+ queue = queue.filter((event) => !ids.has(event.id));
93
+ failures = 0;
94
+ } else
95
+ failures++;
96
+ } catch {
97
+ if (currentGeneration === generation)
98
+ failures++;
99
+ } finally {
100
+ clearTimeout(timeout);
101
+ if (request === controller)
102
+ request = undefined;
103
+ }
104
+ }
105
+ function flush() {
106
+ if (!initialized || blocked())
107
+ return Promise.resolve();
108
+ if (inFlight)
109
+ return inFlight;
110
+ if (!queue.length)
111
+ return Promise.resolve();
112
+ if (timer)
113
+ clearTimeout(timer);
114
+ timer = undefined;
115
+ inFlight = send().finally(() => {
116
+ inFlight = undefined;
117
+ schedule();
118
+ });
119
+ return inFlight;
120
+ }
121
+ function click(event) {
122
+ if (!event.isTrusted || blocked())
123
+ return;
124
+ const target = clickTarget(event, options.capture ?? "interactive");
125
+ if (!target || queue.length >= capacity)
126
+ return;
127
+ try {
128
+ queue.push({
129
+ id: crypto.randomUUID(),
130
+ timestamp: Date.now(),
131
+ page: safePage(options.page?.() ?? "/"),
132
+ ...target
133
+ });
134
+ } catch {
135
+ return;
136
+ }
137
+ if (queue.length >= batchSize && !failures)
138
+ flush();
139
+ else
140
+ schedule();
141
+ }
142
+ function privacyChanged() {
143
+ blocked();
144
+ }
145
+ function visibilityChanged() {
146
+ if (document.visibilityState === "hidden")
147
+ flush();
148
+ }
149
+ function pageHidden() {
150
+ flush();
151
+ }
152
+ function online() {
153
+ failures = 0;
154
+ flush();
155
+ }
156
+ return {
157
+ init() {
158
+ if (initialized)
159
+ return !blocked();
160
+ if (options.enabled === false || typeof window === "undefined" || typeof document === "undefined")
161
+ return false;
162
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(options.site))
163
+ throw new Error("Invalid ct site identifier");
164
+ const url = new URL(endpoint);
165
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname)))
166
+ throw new Error("ct requires an HTTPS endpoint");
167
+ if (navigator.webdriver === true)
168
+ return false;
169
+ initialized = true;
170
+ document.addEventListener("click", click, true);
171
+ document.addEventListener("visibilitychange", visibilityChanged);
172
+ window.addEventListener("pagehide", pageHidden);
173
+ window.addEventListener("online", online);
174
+ window.addEventListener("storage", privacyChanged);
175
+ document.addEventListener(OPT_OUT_EVENT, privacyChanged);
176
+ return !blocked();
177
+ },
178
+ flush,
179
+ destroy() {
180
+ if (initialized) {
181
+ document.removeEventListener("click", click, true);
182
+ document.removeEventListener("visibilitychange", visibilityChanged);
183
+ window.removeEventListener("pagehide", pageHidden);
184
+ window.removeEventListener("online", online);
185
+ window.removeEventListener("storage", privacyChanged);
186
+ document.removeEventListener(OPT_OUT_EVENT, privacyChanged);
187
+ }
188
+ initialized = false;
189
+ clear();
190
+ },
191
+ setOptOut(value) {
192
+ disabled = value;
193
+ if (value)
194
+ clear();
195
+ if (typeof window === "undefined")
196
+ return;
197
+ try {
198
+ if (value)
199
+ localStorage.setItem(OPT_OUT_KEY, "1");
200
+ else
201
+ localStorage.removeItem(OPT_OUT_KEY);
202
+ } catch {}
203
+ document.dispatchEvent(new Event(OPT_OUT_EVENT));
204
+ }
205
+ };
206
+ }
207
+ function bounded(value, fallback, min, max) {
208
+ return value !== undefined && Number.isFinite(value) ? Math.max(min, Math.min(max, Math.floor(value))) : fallback;
209
+ }
210
+ export {
211
+ createTracker
212
+ };