@motionstudies/web 0.1.0-alpha.0
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/LICENSE +21 -0
- package/README.md +26 -0
- package/components/MobilePicker.d.ts +16 -0
- package/components/MobilePicker.js +92 -0
- package/components/mobile-picker.css +147 -0
- package/data-url.d.ts +3 -0
- package/data-url.js +5 -0
- package/mount-motion-study.d.ts +3 -0
- package/mount-motion-study.js +13 -0
- package/package.json +96 -0
- package/recording.d.ts +11 -0
- package/recording.js +62 -0
- package/shell.css +715 -0
- package/tokens.css +17 -0
- package/use-local-performance.d.ts +5 -0
- package/use-local-performance.js +34 -0
- package/use-observed-operations.d.ts +8 -0
- package/use-observed-operations.js +46 -0
- package/use-progressive-air-day.d.ts +10 -0
- package/use-progressive-air-day.js +13 -0
- package/use-progressive-chunks.d.ts +24 -0
- package/use-progressive-chunks.js +91 -0
- package/use-progressive-network-day.d.ts +11 -0
- package/use-progressive-network-day.js +29 -0
- package/use-progressive-road-study.d.ts +10 -0
- package/use-progressive-road-study.js +14 -0
- package/visual-theme.d.ts +2 -0
- package/visual-theme.js +12 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Motion Studies contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Motion Studies packages
|
|
2
|
+
|
|
3
|
+
Shared packages for the Motion Studies transport instrument. The source workspace and compiled distributions expose the same explicit, extensionless module subpaths. The initial release is `0.1.0-alpha.0` under npm’s `next` tag.
|
|
4
|
+
|
|
5
|
+
- `@motionstudies/core`: transport contracts, indexing, interpolation and visual theme contracts; no browser or Node dependencies.
|
|
6
|
+
- `@motionstudies/three`: `NationalNetworkScene`, `HubPulseScene`, `StationFlowScene`, camera framing and label-mode contracts. React, React Three Fiber and Three.js are peers; rendering internals are not public subpaths.
|
|
7
|
+
- `@motionstudies/web`: picker, theme application, mounting, progressive loaders, observed operations and recording. Import `tokens.css` and `mobile-picker.css` for isolated widgets. `shell.css` is an optional full-page study shell scoped to `.motion-study`; `mountMotionStudy` applies that class. Fonts and edition layouts belong to consumers.
|
|
8
|
+
- `@motionstudies/data`: Node-only GTFS readers, network chunking, merging and station ranking. ZIP reading requires `unzip` on the host. Source selection, provenance overrides and compilation commands belong to each edition.
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
import { MobilePicker } from '@motionstudies/web/components/MobilePicker'
|
|
12
|
+
import { createDataUrlResolver } from '@motionstudies/web/data-url'
|
|
13
|
+
import { useProgressiveNetworkDay } from '@motionstudies/web/use-progressive-network-day'
|
|
14
|
+
import '@motionstudies/web/tokens.css'
|
|
15
|
+
import '@motionstudies/web/mobile-picker.css'
|
|
16
|
+
|
|
17
|
+
const resolveData = createDataUrlResolver('/my-edition/data/')
|
|
18
|
+
// Inside a React component:
|
|
19
|
+
// const day = useProgressiveNetworkDay('day.json', enabled, time, resolveData)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Keep the resolver stable across renders. Manifest paths and their chunk paths are relative to the supplied asset root. Changing the resolved manifest URL resets the loader; disabling cancels requests, and re-enabling retries failures. Optional road manifests distinguish a 404 from an error. Observations poll serially; changing their endpoint drops the previous source's state, and disabling/unmounting cancels the request and timer.
|
|
23
|
+
|
|
24
|
+
Build release candidates with `npm run build:packages`. Distribution manifests and compiled ESM/declarations are written to `.package-dist/`; workspace manifests continue to point at source for fast local iteration. `npm run check:packed` packs and installs those distributions into a separate consumer, builds the lab and validates the public exports. No source aliases or workspace links are used in that consumer.
|
|
25
|
+
|
|
26
|
+
Source workspace manifests always stay private. `npm run check:release` builds public candidates, tests their packed consumer and records the tested tarball hashes; `npm run release:dry-run` inspects the publication without writing to npm. The manual main-branch `release.yml` workflow publishes those same tarballs with public access and provenance. See [release instructions](https://github.com/emmettl/motionstudies/blob/main/docs/RELEASING.md) for bootstrap-token and trusted-publisher setup. All four shared packages are MIT-licensed; each distribution includes `LICENSE`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export interface MobilePickerOption {
|
|
3
|
+
value: string;
|
|
4
|
+
label: ReactNode;
|
|
5
|
+
detail?: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
export interface MobilePickerProps {
|
|
8
|
+
ariaLabel: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
menuPlacement?: 'down' | 'up';
|
|
11
|
+
onChange: (value: string) => void;
|
|
12
|
+
options: readonly MobilePickerOption[];
|
|
13
|
+
triggerLabel?: ReactNode;
|
|
14
|
+
value: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function MobilePicker({ ariaLabel, className, menuPlacement, onChange, options, triggerLabel, value, }: MobilePickerProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useRef, useState, } from 'react';
|
|
3
|
+
export function MobilePicker({ ariaLabel, className = '', menuPlacement = 'down', onChange, options, triggerLabel, value, }) {
|
|
4
|
+
const [open, setOpen] = useState(false);
|
|
5
|
+
const rootRef = useRef(null);
|
|
6
|
+
const triggerRef = useRef(null);
|
|
7
|
+
const optionRefs = useRef([]);
|
|
8
|
+
const initialFocusIndex = useRef(0);
|
|
9
|
+
const menuId = useId();
|
|
10
|
+
const selectedIndex = Math.max(0, options.findIndex((option) => option.value === value));
|
|
11
|
+
const selectedOption = options[selectedIndex];
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!open)
|
|
14
|
+
return;
|
|
15
|
+
const handlePointerDown = (event) => {
|
|
16
|
+
if (!rootRef.current?.contains(event.target))
|
|
17
|
+
setOpen(false);
|
|
18
|
+
};
|
|
19
|
+
const handleFocusIn = (event) => {
|
|
20
|
+
if (!rootRef.current?.contains(event.target))
|
|
21
|
+
setOpen(false);
|
|
22
|
+
};
|
|
23
|
+
document.addEventListener('pointerdown', handlePointerDown);
|
|
24
|
+
document.addEventListener('focusin', handleFocusIn);
|
|
25
|
+
return () => {
|
|
26
|
+
document.removeEventListener('pointerdown', handlePointerDown);
|
|
27
|
+
document.removeEventListener('focusin', handleFocusIn);
|
|
28
|
+
};
|
|
29
|
+
}, [open]);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (open)
|
|
32
|
+
optionRefs.current[initialFocusIndex.current]?.focus();
|
|
33
|
+
}, [open]);
|
|
34
|
+
const openAt = (index) => {
|
|
35
|
+
initialFocusIndex.current = index;
|
|
36
|
+
setOpen(true);
|
|
37
|
+
};
|
|
38
|
+
const closeAndRestoreFocus = () => {
|
|
39
|
+
setOpen(false);
|
|
40
|
+
triggerRef.current?.focus();
|
|
41
|
+
};
|
|
42
|
+
const handleTriggerKeyDown = (event) => {
|
|
43
|
+
if (event.key === 'ArrowDown') {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
openAt(selectedIndex);
|
|
46
|
+
}
|
|
47
|
+
else if (event.key === 'ArrowUp') {
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
openAt(options.length - 1);
|
|
50
|
+
}
|
|
51
|
+
else if (event.key === 'Home') {
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
openAt(0);
|
|
54
|
+
}
|
|
55
|
+
else if (event.key === 'End') {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
openAt(options.length - 1);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const handleOptionKeyDown = (event, index) => {
|
|
61
|
+
let nextIndex;
|
|
62
|
+
if (event.key === 'ArrowDown')
|
|
63
|
+
nextIndex = (index + 1) % options.length;
|
|
64
|
+
else if (event.key === 'ArrowUp') {
|
|
65
|
+
nextIndex = (index - 1 + options.length) % options.length;
|
|
66
|
+
}
|
|
67
|
+
else if (event.key === 'Home')
|
|
68
|
+
nextIndex = 0;
|
|
69
|
+
else if (event.key === 'End')
|
|
70
|
+
nextIndex = options.length - 1;
|
|
71
|
+
else if (event.key === 'Escape') {
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
closeAndRestoreFocus();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (nextIndex !== undefined) {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
optionRefs.current[nextIndex]?.focus();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
return (_jsxs("div", { ref: rootRef, className: `mobile-picker mobile-picker--${menuPlacement}${className ? ` ${className}` : ''}`, children: [_jsxs("button", { ref: triggerRef, className: "mobile-picker__trigger", type: "button", "aria-label": ariaLabel, disabled: options.length === 0, "aria-haspopup": "listbox", "aria-controls": menuId, "aria-expanded": open, onClick: () => {
|
|
82
|
+
if (open)
|
|
83
|
+
setOpen(false);
|
|
84
|
+
else
|
|
85
|
+
openAt(selectedIndex);
|
|
86
|
+
}, onKeyDown: handleTriggerKeyDown, children: [_jsx("span", { children: triggerLabel ?? selectedOption?.label }), _jsx("i", { "aria-hidden": "true" })] }), open && (_jsx("div", { id: menuId, className: "mobile-picker__menu", role: "listbox", "aria-label": ariaLabel, children: options.map((option, index) => (_jsxs("button", { ref: (element) => {
|
|
87
|
+
optionRefs.current[index] = element;
|
|
88
|
+
}, type: "button", role: "option", "aria-selected": option.value === value, onClick: () => {
|
|
89
|
+
onChange(option.value);
|
|
90
|
+
closeAndRestoreFocus();
|
|
91
|
+
}, onKeyDown: (event) => handleOptionKeyDown(event, index), children: [_jsx("span", { children: option.label }), option.detail && _jsx("small", { children: option.detail })] }, option.value))) }))] }));
|
|
92
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/* Import tokens.css once in the consumer. Layout and visibility remain local. */
|
|
2
|
+
.mobile-picker,
|
|
3
|
+
.mobile-picker * {
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
:where(.mobile-picker) button {
|
|
8
|
+
font: inherit;
|
|
9
|
+
-webkit-tap-highlight-color: transparent;
|
|
10
|
+
border: 1px solid var(--line);
|
|
11
|
+
cursor: pointer;
|
|
12
|
+
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.mobile-picker {
|
|
16
|
+
position: relative;
|
|
17
|
+
font-family: 'DM Mono', ui-monospace, monospace;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.mobile-picker__trigger {
|
|
21
|
+
position: relative;
|
|
22
|
+
display: flex;
|
|
23
|
+
min-height: 42px;
|
|
24
|
+
align-items: center;
|
|
25
|
+
justify-content: space-between;
|
|
26
|
+
gap: 8px;
|
|
27
|
+
padding: 0 9px;
|
|
28
|
+
border: 1px solid rgba(193, 204, 255, 0.24);
|
|
29
|
+
outline: 0;
|
|
30
|
+
background: rgba(7, 7, 22, 0.82);
|
|
31
|
+
color: var(--ink);
|
|
32
|
+
font-size: 0.66rem;
|
|
33
|
+
letter-spacing: 0.04em;
|
|
34
|
+
backdrop-filter: blur(14px);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.mobile-picker__trigger i {
|
|
38
|
+
width: 6px;
|
|
39
|
+
height: 6px;
|
|
40
|
+
border-right: 1px solid var(--cyan);
|
|
41
|
+
border-bottom: 1px solid var(--cyan);
|
|
42
|
+
transform: translateY(-2px) rotate(45deg);
|
|
43
|
+
transition: transform 140ms ease;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.mobile-picker__trigger[aria-expanded='true'] {
|
|
47
|
+
border-color: rgba(141, 250, 255, 0.68);
|
|
48
|
+
background: rgba(26, 23, 64, 0.94);
|
|
49
|
+
box-shadow: inset 0 -1px 0 var(--cyan), 0 0 18px rgba(141, 250, 255, 0.11);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.mobile-picker__trigger[aria-expanded='true'] i {
|
|
53
|
+
transform: translateY(2px) rotate(225deg);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.mobile-picker__menu {
|
|
57
|
+
position: absolute;
|
|
58
|
+
top: calc(100% + 5px);
|
|
59
|
+
right: 0;
|
|
60
|
+
z-index: 30;
|
|
61
|
+
display: grid;
|
|
62
|
+
width: max-content;
|
|
63
|
+
min-width: 180px;
|
|
64
|
+
max-width: min(260px, calc(100vw - 24px));
|
|
65
|
+
padding: 5px;
|
|
66
|
+
border: 1px solid rgba(141, 250, 255, 0.38);
|
|
67
|
+
background:
|
|
68
|
+
linear-gradient(145deg, rgba(20, 18, 50, 0.98), rgba(5, 5, 18, 0.98));
|
|
69
|
+
box-shadow: 0 15px 42px rgba(0, 0, 0, 0.56), 0 0 28px rgba(69, 89, 156, 0.12);
|
|
70
|
+
backdrop-filter: blur(22px);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.mobile-picker--up .mobile-picker__menu {
|
|
74
|
+
top: auto;
|
|
75
|
+
bottom: calc(100% + 5px);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.mobile-picker__menu::before {
|
|
79
|
+
position: absolute;
|
|
80
|
+
inset: 0;
|
|
81
|
+
border-top: 1px solid rgba(141, 250, 255, 0.22);
|
|
82
|
+
pointer-events: none;
|
|
83
|
+
content: '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.mobile-picker__menu > button {
|
|
87
|
+
position: relative;
|
|
88
|
+
display: grid;
|
|
89
|
+
width: 100%;
|
|
90
|
+
min-height: 44px;
|
|
91
|
+
grid-template-columns: 42px minmax(0, 1fr);
|
|
92
|
+
align-items: center;
|
|
93
|
+
gap: 9px;
|
|
94
|
+
padding: 6px 9px;
|
|
95
|
+
border-color: transparent;
|
|
96
|
+
background: transparent;
|
|
97
|
+
color: var(--muted);
|
|
98
|
+
font-size: 0.67rem;
|
|
99
|
+
text-align: left;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.mobile-picker__menu > button + button {
|
|
103
|
+
border-top-color: rgba(193, 204, 255, 0.08);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.mobile-picker__menu > button > span {
|
|
107
|
+
color: var(--ink);
|
|
108
|
+
letter-spacing: 0.08em;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.mobile-picker__menu > button > small {
|
|
112
|
+
overflow: hidden;
|
|
113
|
+
color: rgba(229, 231, 255, 0.52);
|
|
114
|
+
font-size: 0.54rem;
|
|
115
|
+
letter-spacing: 0.02em;
|
|
116
|
+
line-height: 1.2;
|
|
117
|
+
text-overflow: ellipsis;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.mobile-picker__menu > button[aria-selected='true'] {
|
|
121
|
+
border-color: rgba(255, 94, 219, 0.28);
|
|
122
|
+
background: linear-gradient(90deg, rgba(65, 46, 126, 0.84), rgba(25, 22, 61, 0.58));
|
|
123
|
+
color: var(--cyan);
|
|
124
|
+
box-shadow: inset 2px 0 0 var(--pink), 0 0 16px rgba(255, 94, 219, 0.08);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.mobile-picker__menu > button:focus-visible {
|
|
128
|
+
outline: 1px solid var(--cyan);
|
|
129
|
+
outline-offset: -2px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.mobile-picker__trigger:focus-visible {
|
|
133
|
+
outline: 2px solid var(--cyan);
|
|
134
|
+
outline-offset: 3px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.mobile-picker__trigger:disabled {
|
|
138
|
+
cursor: default;
|
|
139
|
+
opacity: 0.5;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
@media (prefers-reduced-motion: reduce) {
|
|
143
|
+
.mobile-picker button,
|
|
144
|
+
.mobile-picker__trigger i {
|
|
145
|
+
transition: none;
|
|
146
|
+
}
|
|
147
|
+
}
|
package/data-url.d.ts
ADDED
package/data-url.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { StrictMode } from 'react';
|
|
3
|
+
import { createRoot } from 'react-dom/client';
|
|
4
|
+
import { applyVisualTheme } from './visual-theme.js';
|
|
5
|
+
export function mountMotionStudy(edition, application) {
|
|
6
|
+
const root = document.getElementById('root');
|
|
7
|
+
if (!root)
|
|
8
|
+
throw new Error('Motion Studies entry point requires #root');
|
|
9
|
+
document.documentElement.classList.add('motion-study');
|
|
10
|
+
document.documentElement.dataset.edition = edition.id;
|
|
11
|
+
applyVisualTheme(edition.theme);
|
|
12
|
+
createRoot(root).render(_jsx(StrictMode, { children: application }));
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@motionstudies/web",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Browser controls and loaders for Motion Studies.",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/emmettl/motionstudies.git",
|
|
11
|
+
"directory": "packages/web"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/emmettl/motionstudies#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/emmettl/motionstudies/issues"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public",
|
|
19
|
+
"registry": "https://registry.npmjs.org/",
|
|
20
|
+
"tag": "next"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
"./components/MobilePicker": {
|
|
24
|
+
"types": "./components/MobilePicker.d.ts",
|
|
25
|
+
"import": "./components/MobilePicker.js",
|
|
26
|
+
"default": "./components/MobilePicker.js"
|
|
27
|
+
},
|
|
28
|
+
"./data-url": {
|
|
29
|
+
"types": "./data-url.d.ts",
|
|
30
|
+
"import": "./data-url.js",
|
|
31
|
+
"default": "./data-url.js"
|
|
32
|
+
},
|
|
33
|
+
"./mount-motion-study": {
|
|
34
|
+
"types": "./mount-motion-study.d.ts",
|
|
35
|
+
"import": "./mount-motion-study.js",
|
|
36
|
+
"default": "./mount-motion-study.js"
|
|
37
|
+
},
|
|
38
|
+
"./recording": {
|
|
39
|
+
"types": "./recording.d.ts",
|
|
40
|
+
"import": "./recording.js",
|
|
41
|
+
"default": "./recording.js"
|
|
42
|
+
},
|
|
43
|
+
"./visual-theme": {
|
|
44
|
+
"types": "./visual-theme.d.ts",
|
|
45
|
+
"import": "./visual-theme.js",
|
|
46
|
+
"default": "./visual-theme.js"
|
|
47
|
+
},
|
|
48
|
+
"./use-local-performance": {
|
|
49
|
+
"types": "./use-local-performance.d.ts",
|
|
50
|
+
"import": "./use-local-performance.js",
|
|
51
|
+
"default": "./use-local-performance.js"
|
|
52
|
+
},
|
|
53
|
+
"./use-observed-operations": {
|
|
54
|
+
"types": "./use-observed-operations.d.ts",
|
|
55
|
+
"import": "./use-observed-operations.js",
|
|
56
|
+
"default": "./use-observed-operations.js"
|
|
57
|
+
},
|
|
58
|
+
"./use-progressive-air-day": {
|
|
59
|
+
"types": "./use-progressive-air-day.d.ts",
|
|
60
|
+
"import": "./use-progressive-air-day.js",
|
|
61
|
+
"default": "./use-progressive-air-day.js"
|
|
62
|
+
},
|
|
63
|
+
"./use-progressive-network-day": {
|
|
64
|
+
"types": "./use-progressive-network-day.d.ts",
|
|
65
|
+
"import": "./use-progressive-network-day.js",
|
|
66
|
+
"default": "./use-progressive-network-day.js"
|
|
67
|
+
},
|
|
68
|
+
"./use-progressive-road-study": {
|
|
69
|
+
"types": "./use-progressive-road-study.d.ts",
|
|
70
|
+
"import": "./use-progressive-road-study.js",
|
|
71
|
+
"default": "./use-progressive-road-study.js"
|
|
72
|
+
},
|
|
73
|
+
"./tokens.css": "./tokens.css",
|
|
74
|
+
"./shell.css": "./shell.css",
|
|
75
|
+
"./mobile-picker.css": "./components/mobile-picker.css"
|
|
76
|
+
},
|
|
77
|
+
"files": [
|
|
78
|
+
"**/*.js",
|
|
79
|
+
"**/*.mjs",
|
|
80
|
+
"**/*.d.ts",
|
|
81
|
+
"**/*.d.mts",
|
|
82
|
+
"**/*.css",
|
|
83
|
+
"README.md",
|
|
84
|
+
"LICENSE"
|
|
85
|
+
],
|
|
86
|
+
"sideEffects": [
|
|
87
|
+
"**/*.css"
|
|
88
|
+
],
|
|
89
|
+
"dependencies": {
|
|
90
|
+
"@motionstudies/core": "0.1.0-alpha.0"
|
|
91
|
+
},
|
|
92
|
+
"peerDependencies": {
|
|
93
|
+
"react": "^19.2.8",
|
|
94
|
+
"react-dom": "^19.2.8"
|
|
95
|
+
}
|
|
96
|
+
}
|
package/recording.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface RecordingOptions {
|
|
2
|
+
readonly duration: number;
|
|
3
|
+
readonly fileNamePrefix?: string;
|
|
4
|
+
readonly onSaving: () => void;
|
|
5
|
+
readonly onComplete: () => void;
|
|
6
|
+
}
|
|
7
|
+
export interface CanvasRecording {
|
|
8
|
+
readonly stop: () => void;
|
|
9
|
+
readonly cancel: () => void;
|
|
10
|
+
}
|
|
11
|
+
export declare function recordCanvas(canvas: HTMLCanvasElement, options: RecordingOptions): CanvasRecording;
|
package/recording.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
function recordingType() {
|
|
2
|
+
const types = [
|
|
3
|
+
{ mimeType: 'video/mp4;codecs=h264', extension: 'mp4' },
|
|
4
|
+
{ mimeType: 'video/webm;codecs=vp9', extension: 'webm' },
|
|
5
|
+
{ mimeType: 'video/webm;codecs=vp8', extension: 'webm' },
|
|
6
|
+
{ mimeType: 'video/webm', extension: 'webm' },
|
|
7
|
+
];
|
|
8
|
+
return types.find(({ mimeType }) => MediaRecorder.isTypeSupported(mimeType)) ?? {
|
|
9
|
+
mimeType: '',
|
|
10
|
+
extension: 'webm',
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function fileStamp() {
|
|
14
|
+
return new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
|
|
15
|
+
}
|
|
16
|
+
export function recordCanvas(canvas, options) {
|
|
17
|
+
const stream = canvas.captureStream(30);
|
|
18
|
+
const type = recordingType();
|
|
19
|
+
const recorder = new MediaRecorder(stream, type.mimeType ? { mimeType: type.mimeType, videoBitsPerSecond: 8_000_000 } : undefined);
|
|
20
|
+
const chunks = [];
|
|
21
|
+
let stopped = false;
|
|
22
|
+
let discarded = false;
|
|
23
|
+
const timer = window.setTimeout(() => stop(), options.duration);
|
|
24
|
+
recorder.addEventListener('dataavailable', (event) => {
|
|
25
|
+
if (event.data.size)
|
|
26
|
+
chunks.push(event.data);
|
|
27
|
+
});
|
|
28
|
+
recorder.addEventListener('stop', () => {
|
|
29
|
+
window.clearTimeout(timer);
|
|
30
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
31
|
+
if (discarded) {
|
|
32
|
+
options.onComplete();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const blob = new Blob(chunks, { type: recorder.mimeType || type.mimeType });
|
|
36
|
+
const url = URL.createObjectURL(blob);
|
|
37
|
+
const link = document.createElement('a');
|
|
38
|
+
link.href = url;
|
|
39
|
+
link.download = `${options.fileNamePrefix ?? 'motion-study'}-${fileStamp()}.${type.extension}`;
|
|
40
|
+
link.click();
|
|
41
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
|
|
42
|
+
options.onComplete();
|
|
43
|
+
});
|
|
44
|
+
recorder.addEventListener('error', () => {
|
|
45
|
+
window.clearTimeout(timer);
|
|
46
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
47
|
+
options.onComplete();
|
|
48
|
+
});
|
|
49
|
+
recorder.start(1_000);
|
|
50
|
+
function stop() {
|
|
51
|
+
if (stopped || recorder.state === 'inactive')
|
|
52
|
+
return;
|
|
53
|
+
stopped = true;
|
|
54
|
+
options.onSaving();
|
|
55
|
+
recorder.stop();
|
|
56
|
+
}
|
|
57
|
+
function cancel() {
|
|
58
|
+
discarded = true;
|
|
59
|
+
stop();
|
|
60
|
+
}
|
|
61
|
+
return { stop, cancel };
|
|
62
|
+
}
|