six-degrees 0.4.4 → 0.4.5
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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +4 -4
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/admin-delete/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/admin-update/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/bridges/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/bulk-import/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/company-scores/legacy/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/company-scores/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/connections/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/export/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/import/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/restart/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/reveal/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/delete-cluster/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/ingest/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/network/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/outreach/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/queue/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/scraper/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/settings/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/settings/sector-preview/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/settings/sector-suggestions/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/setup-profile/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/social/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/unlock/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/update/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/update-images/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/users/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/avatars/[file]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/import.html +1 -1
- package/.next/standalone/.next/server/app/import.rsc +1 -1
- package/.next/standalone/.next/server/app/import.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/import.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/import.segments/import/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/launch.html +1 -1
- package/.next/standalone/.next/server/app/launch.rsc +1 -1
- package/.next/standalone/.next/server/app/launch.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/launch.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/launch.segments/launch/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/paths/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/paths/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/paths.html +1 -1
- package/.next/standalone/.next/server/app/paths.rsc +2 -2
- package/.next/standalone/.next/server/app/paths.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/paths.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/paths.segments/paths/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/profile.html +1 -1
- package/.next/standalone/.next/server/app/profile.rsc +1 -1
- package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/profile.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/profile.segments/profile/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/queue.html +1 -1
- package/.next/standalone/.next/server/app/queue.rsc +1 -1
- package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/queue.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/queue.segments/queue/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/scores.html +1 -1
- package/.next/standalone/.next/server/app/scores.rsc +1 -1
- package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/settings.html +1 -1
- package/.next/standalone/.next/server/app/settings.rsc +1 -1
- package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/setup.html +1 -1
- package/.next/standalone/.next/server/app/setup.rsc +1 -1
- package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/social/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/social/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/social.html +1 -1
- package/.next/standalone/.next/server/app/social.rsc +2 -2
- package/.next/standalone/.next/server/app/social.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/social.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/social.segments/social/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/chunks/_1itd1o_._.js +1 -1
- package/.next/standalone/.next/server/chunks/lib_db-client_15le7cw.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0ezj4e0._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_14iv_zy._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_212xw8q._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_217nddo._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/app_0i3fw_m._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/app_page_1fazqbf.js +17 -4
- package/.next/standalone/.next/server/chunks/ssr/app_paths_page_1ol90m7.js +2 -2
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware-manifest.json +5 -5
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/0y1yms5kez2f3.js +1 -0
- package/.next/standalone/.next/static/chunks/14m1zu-8warte.js +1 -0
- package/.next/standalone/.next/static/chunks/1e1n0kkz-psdb.js +1 -0
- package/.next/standalone/.next/static/chunks/2_zu7rmb8flzl.js +2 -0
- package/.next/standalone/.next/static/chunks/38wwtni5e1ec1.js +31 -0
- package/.next/standalone/CHANGELOG.md +22 -0
- package/.next/standalone/app/components/FilterPanel.js +6 -0
- package/.next/standalone/app/components/ForceGraph.js +257 -39
- package/.next/standalone/app/components/GalaxyLab.js +275 -0
- package/.next/standalone/app/social/page.js +4 -16
- package/.next/standalone/lib/galaxy-export.js +152 -0
- package/.next/standalone/lib/galaxy-lab.js +296 -0
- package/.next/standalone/package-lock.json +2 -2
- package/.next/standalone/package.json +1 -1
- package/.next/static/chunks/0y1yms5kez2f3.js +1 -0
- package/.next/static/chunks/14m1zu-8warte.js +1 -0
- package/.next/static/chunks/1e1n0kkz-psdb.js +1 -0
- package/.next/static/chunks/2_zu7rmb8flzl.js +2 -0
- package/.next/static/chunks/38wwtni5e1ec1.js +31 -0
- package/CHANGELOG.md +22 -0
- package/lib/galaxy-export.js +152 -0
- package/lib/galaxy-lab.js +296 -0
- package/package.json +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_01f38ty._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_08_03wh._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_08cfj2b._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_1gpbbw9._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/app_social_page_0h7zxda.js +0 -3
- package/.next/standalone/.next/static/chunks/0l1568qff0efj.js +0 -1
- package/.next/standalone/.next/static/chunks/1ra4cwcp75tfa.js +0 -1
- package/.next/standalone/.next/static/chunks/2hvcevue9aro4.js +0 -1
- package/.next/standalone/.next/static/chunks/2p5yt1o9tnxjn.js +0 -2
- package/.next/standalone/.next/static/chunks/2uo9i03_05rqi.js +0 -18
- package/.next/standalone/.next/static/chunks/3yc3rzp4dxvyo.js +0 -1
- package/.next/static/chunks/0l1568qff0efj.js +0 -1
- package/.next/static/chunks/1ra4cwcp75tfa.js +0 -1
- package/.next/static/chunks/2hvcevue9aro4.js +0 -1
- package/.next/static/chunks/2p5yt1o9tnxjn.js +0 -2
- package/.next/static/chunks/2uo9i03_05rqi.js +0 -18
- package/.next/static/chunks/3yc3rzp4dxvyo.js +0 -1
- /package/.next/standalone/.next/static/{VE8LeJXfD2H0_OgJxuJcm → 6LINKa6nyesvIEUnLDSck}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{VE8LeJXfD2H0_OgJxuJcm → 6LINKa6nyesvIEUnLDSck}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{VE8LeJXfD2H0_OgJxuJcm → 6LINKa6nyesvIEUnLDSck}/_ssgManifest.js +0 -0
- /package/.next/static/{VE8LeJXfD2H0_OgJxuJcm → 6LINKa6nyesvIEUnLDSck}/_buildManifest.js +0 -0
- /package/.next/static/{VE8LeJXfD2H0_OgJxuJcm → 6LINKa6nyesvIEUnLDSck}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{VE8LeJXfD2H0_OgJxuJcm → 6LINKa6nyesvIEUnLDSck}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// The Galaxy's physics lab (experimental), in the Filter panel: Obsidian-style
|
|
4
|
+
// sliders for the forces that lay the Galaxy out, and a replay of your network
|
|
5
|
+
// growing by the date you connected. Everything moves the Galaxy in place;
|
|
6
|
+
// lib/galaxy-lab.js holds the settings and the clock.
|
|
7
|
+
|
|
8
|
+
import { useState, useSyncExternalStore } from 'react';
|
|
9
|
+
import {
|
|
10
|
+
LAB_DEFAULTS, CLUSTERS, labNow, setLab, watchLab,
|
|
11
|
+
clockNow, setClock, watchClock, play, pause, stopReplay,
|
|
12
|
+
layoutsNow, watchLayouts, saveLayout, applyLayout, forgetLayout, milestones,
|
|
13
|
+
} from '../../lib/galaxy-lab';
|
|
14
|
+
import { savePicture, recordReplay, canRecord } from '../../lib/galaxy-export';
|
|
15
|
+
|
|
16
|
+
const noLayouts = [];
|
|
17
|
+
|
|
18
|
+
const DAY = 86400000;
|
|
19
|
+
|
|
20
|
+
const FORCES = [
|
|
21
|
+
{ key: 'gravity', label: 'Gravity', min: 0, max: 3, step: 0.01, fmt: (v) => v.toFixed(2), hint: 'Pulls every dot in towards you.' },
|
|
22
|
+
{ key: 'rings', label: 'Rings', min: 0, max: 5, step: 0.05, fmt: (v) => `${v.toFixed(2)}×`, hint: 'How hard each tier holds its ring. At 0 the Galaxy finds its own shape.' },
|
|
23
|
+
{ key: 'push', label: 'Push', min: 0, max: 300, step: 1, fmt: (v) => String(v), hint: 'How hard dots push each other apart.' },
|
|
24
|
+
{ key: 'pull', label: 'Pull', min: 0, max: 15, step: 0.05, fmt: (v) => `${v.toFixed(2)}×`, hint: 'How hard each person pulls the people who came through them.' },
|
|
25
|
+
{ key: 'distance', label: 'Distance', min: 0.05, max: 6, step: 0.05, fmt: (v) => `${v.toFixed(2)}×`, hint: 'How far out those people sit.' },
|
|
26
|
+
];
|
|
27
|
+
const DISPLAY = [
|
|
28
|
+
{ key: 'dotSize', label: 'Dot size', min: 0.2, max: 8, step: 0.05, fmt: (v) => `${v.toFixed(2)}×` },
|
|
29
|
+
{ key: 'lines', label: 'Lines', min: 0, max: 20, step: 0.1, fmt: (v) => `${v.toFixed(1)}×` },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const heading = { fontSize: 9, fontWeight: 700, color: '#555', letterSpacing: 1, margin: '12px 0 6px', textTransform: 'uppercase' };
|
|
33
|
+
const small = { fontSize: 10, color: '#667', lineHeight: 1.4 };
|
|
34
|
+
|
|
35
|
+
function Slider({ spec, value }) {
|
|
36
|
+
return (
|
|
37
|
+
<label title={spec.hint} style={{ display: 'block', marginBottom: 7 }}>
|
|
38
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#aab' }}>
|
|
39
|
+
<span>{spec.label}</span>
|
|
40
|
+
<span style={{ color: value === LAB_DEFAULTS[spec.key] ? '#556' : '#cfe6f7', fontVariantNumeric: 'tabular-nums' }}>{spec.fmt(value)}</span>
|
|
41
|
+
</div>
|
|
42
|
+
<input
|
|
43
|
+
type="range" min={spec.min} max={spec.max} step={spec.step} value={value}
|
|
44
|
+
onChange={(e) => setLab({ [spec.key]: Number(e.target.value) })}
|
|
45
|
+
style={{ width: '100%', accentColor: '#3498DB' }}
|
|
46
|
+
/>
|
|
47
|
+
</label>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Choice({ options, value, onPick }) {
|
|
52
|
+
return (
|
|
53
|
+
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
|
54
|
+
{options.map(([v, label]) => (
|
|
55
|
+
<button key={v} onClick={() => onPick(v)} aria-pressed={value === v} style={{
|
|
56
|
+
flex: 1, padding: '5px 4px', borderRadius: 6, fontSize: 10.5, fontWeight: 600, cursor: 'pointer',
|
|
57
|
+
border: value === v ? '1px solid rgba(52,152,219,0.6)' : '1px solid rgba(255,255,255,0.08)',
|
|
58
|
+
background: value === v ? 'rgba(52,152,219,0.18)' : 'rgba(255,255,255,0.03)',
|
|
59
|
+
color: value === v ? '#cfe6f7' : '#888',
|
|
60
|
+
}}>{label}</button>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Filter → Names: every name on the Galaxy on or off, with or without the lab. */
|
|
67
|
+
export function NamesSwitch() {
|
|
68
|
+
const lab = useSyncExternalStore(watchLab, labNow, () => LAB_DEFAULTS);
|
|
69
|
+
return (
|
|
70
|
+
<div style={{ marginBottom: 18 }}>
|
|
71
|
+
<div style={{ fontSize: 9, fontWeight: 700, color: '#555', letterSpacing: 1, marginBottom: 8, textTransform: 'uppercase' }}>Names</div>
|
|
72
|
+
<Choice value={lab.labels} options={[[true, 'On'], [false, 'Off']]} onPick={(v) => setLab({ labels: v })} />
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const month = (t) => new Date(t).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
|
|
78
|
+
|
|
79
|
+
export default function GalaxyLab() {
|
|
80
|
+
const lab = useSyncExternalStore(watchLab, labNow, () => LAB_DEFAULTS);
|
|
81
|
+
const clock = useSyncExternalStore(watchClock, clockNow, clockNow);
|
|
82
|
+
const layouts = useSyncExternalStore(watchLayouts, layoutsNow, () => noLayouts);
|
|
83
|
+
const [name, setName] = useState('');
|
|
84
|
+
const [busy, setBusy] = useState(null); // 'picture' | 'video' while saving
|
|
85
|
+
const [note, setNote] = useState(null);
|
|
86
|
+
const recordable = useSyncExternalStore(() => () => {}, canRecord, () => false);
|
|
87
|
+
const marks = clock.min != null && clock.max > clock.min
|
|
88
|
+
? milestones(clock.social).filter((m) => m.t >= clock.min && m.t <= clock.max) : [];
|
|
89
|
+
const run = async (what, job) => {
|
|
90
|
+
setBusy(what);
|
|
91
|
+
setNote(null);
|
|
92
|
+
try { await job(); } catch (e) { setNote(e.message || 'That didn’t work.'); } finally { setBusy(null); }
|
|
93
|
+
};
|
|
94
|
+
const canReplay = clock.min != null && clock.max != null && clock.max > clock.min;
|
|
95
|
+
const preset = FORCES.every((f) => lab[f.key] === LAB_DEFAULTS[f.key]) ? 'rings'
|
|
96
|
+
: Object.entries(CLUSTERS).every(([k, v]) => lab[k] === v) ? 'clusters' : null;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div style={{ marginBottom: 18, padding: '10px 10px 8px', borderRadius: 8, border: '1px solid rgba(212,175,55,0.25)', background: 'rgba(212,175,55,0.04)' }}>
|
|
100
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
|
101
|
+
<input
|
|
102
|
+
type="checkbox" checked={lab.on}
|
|
103
|
+
onChange={(e) => { if (!e.target.checked) { stopReplay(); setClock({ find: '' }); } setLab({ on: e.target.checked }); }}
|
|
104
|
+
style={{ accentColor: '#D4AF37' }}
|
|
105
|
+
/>
|
|
106
|
+
<span style={{ fontSize: 11.5, fontWeight: 700, color: '#e6d7a0' }}>Physics lab</span>
|
|
107
|
+
<span style={{ fontSize: 9, fontWeight: 700, color: '#D4AF37', border: '1px solid rgba(212,175,55,0.4)', borderRadius: 4, padding: '0 4px' }}>EXPERIMENTAL</span>
|
|
108
|
+
</label>
|
|
109
|
+
{!lab.on && (
|
|
110
|
+
<div style={{ ...small, marginTop: 6 }}>
|
|
111
|
+
Sliders for the forces that lay the Galaxy out, colours by company or warmth, find, a branch that lights
|
|
112
|
+
up on hover, and a replay of your network growing that you can save as a video.
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{lab.on && (
|
|
117
|
+
<>
|
|
118
|
+
<div style={heading}>Find</div>
|
|
119
|
+
<input
|
|
120
|
+
type="search" value={clock.find} placeholder="A name, company or role"
|
|
121
|
+
onChange={(e) => setClock({ find: e.target.value })}
|
|
122
|
+
onKeyDown={(e) => { if (e.key === 'Enter') setClock({ fly: clock.fly + 1 }); }}
|
|
123
|
+
style={{
|
|
124
|
+
width: '100%', boxSizing: 'border-box', padding: '6px 9px', borderRadius: 6, fontSize: 11.5,
|
|
125
|
+
border: '1px solid rgba(255,255,255,0.12)', background: 'rgba(0,0,0,0.25)', color: '#dde',
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
{clock.find.trim().length >= 2 && (
|
|
129
|
+
<div style={{ ...small, marginTop: 4 }}>
|
|
130
|
+
{clock.found ? `${clock.found.toLocaleString()} found · Enter flies to the best one` : 'No one on screen matches.'}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
<div style={heading}>Layout</div>
|
|
135
|
+
<Choice
|
|
136
|
+
value={preset}
|
|
137
|
+
options={[['rings', 'Rings'], ['clusters', 'Clusters']]}
|
|
138
|
+
onPick={(v) => setLab(v === 'rings' ? { ...Object.fromEntries(FORCES.map((f) => [f.key, LAB_DEFAULTS[f.key]])), sizeBy: 'power' } : CLUSTERS)}
|
|
139
|
+
/>
|
|
140
|
+
<div style={{ ...small, marginBottom: 6 }}>
|
|
141
|
+
{preset === 'clusters'
|
|
142
|
+
? 'No rings: each connection pulls their circle round them, and a dot grows with everyone behind it.'
|
|
143
|
+
: 'Drag a dot to see what it pulls with it. Try Rings at 0 and Pull up.'}
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div style={heading}>Forces</div>
|
|
147
|
+
{FORCES.map((f) => <Slider key={f.key} spec={f} value={lab[f.key]} />)}
|
|
148
|
+
|
|
149
|
+
<div style={heading}>Display</div>
|
|
150
|
+
{DISPLAY.map((f) => <Slider key={f.key} spec={f} value={lab[f.key]} />)}
|
|
151
|
+
<div style={{ fontSize: 11, color: '#aab', marginBottom: 4 }}>Size dots by</div>
|
|
152
|
+
<Choice value={lab.sizeBy} options={[['power', 'Power score'], ['reach', 'Who hangs off them']]} onPick={(v) => setLab({ sizeBy: v })} />
|
|
153
|
+
<div style={{ fontSize: 11, color: '#aab', marginBottom: 4 }}>Colour by</div>
|
|
154
|
+
<Choice
|
|
155
|
+
value={lab.colourBy}
|
|
156
|
+
options={[['tier', 'Tier'], ['degree', 'Degree'], ['company', 'Company'], ['warmth', 'Warmth']]}
|
|
157
|
+
onPick={(v) => setLab({ colourBy: v })}
|
|
158
|
+
/>
|
|
159
|
+
{lab.colourBy === 'warmth' && clock.social === null && (
|
|
160
|
+
<div style={{ ...small, marginTop: -4, marginBottom: 8 }}>
|
|
161
|
+
Warmth comes from the Social tab: import your LinkedIn export there first. Until then it’s by tier.
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
<div style={{ fontSize: 11, color: '#aab', marginBottom: 4 }}>Which names{lab.labels ? '' : ' (Names is off)'}</div>
|
|
165
|
+
<Choice value={lab.names === 'all' ? 'all' : 'key'} options={[['key', 'S + catalysts'], ['all', 'All 1st']]} onPick={(v) => setLab({ names: v })} />
|
|
166
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#aab', cursor: 'pointer' }}>
|
|
167
|
+
<input type="checkbox" checked={lab.branch} onChange={(e) => setLab({ branch: e.target.checked })} style={{ accentColor: '#3498DB' }} />
|
|
168
|
+
Light up a branch on hover
|
|
169
|
+
</label>
|
|
170
|
+
|
|
171
|
+
<div style={heading}>Replay</div>
|
|
172
|
+
{canReplay ? (
|
|
173
|
+
<>
|
|
174
|
+
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
|
|
175
|
+
<button onClick={() => (clock.playing ? pause() : play())} style={{
|
|
176
|
+
padding: '5px 12px', borderRadius: 6, fontSize: 11, fontWeight: 700, cursor: 'pointer',
|
|
177
|
+
border: '1px solid rgba(52,152,219,0.6)', background: 'rgba(52,152,219,0.18)', color: '#cfe6f7',
|
|
178
|
+
}}>{clock.playing ? '❚❚ Pause' : '▶ Replay'}</button>
|
|
179
|
+
{clock.at != null && (
|
|
180
|
+
<button onClick={stopReplay} style={{
|
|
181
|
+
padding: '5px 10px', borderRadius: 6, fontSize: 11, cursor: 'pointer',
|
|
182
|
+
border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.04)', color: '#aaa',
|
|
183
|
+
}}>Show all</button>
|
|
184
|
+
)}
|
|
185
|
+
<span style={{ marginLeft: 'auto', fontSize: 10.5, color: '#aab', fontVariantNumeric: 'tabular-nums' }}>
|
|
186
|
+
{month(clock.at ?? clock.max)}
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
<div style={{ position: 'relative' }}>
|
|
190
|
+
<input
|
|
191
|
+
type="range" min={clock.min} max={clock.max} step={DAY} value={clock.at ?? clock.max}
|
|
192
|
+
onChange={(e) => { pause(); setClock({ at: Number(e.target.value) }); }}
|
|
193
|
+
style={{ width: '100%', accentColor: '#3498DB' }}
|
|
194
|
+
aria-label="Time"
|
|
195
|
+
/>
|
|
196
|
+
{/* Milestones from the Social tab: a job start (gold) or a post (blue). */}
|
|
197
|
+
<div style={{ position: 'absolute', left: 7, right: 7, top: -3, height: 6, pointerEvents: 'none' }}>
|
|
198
|
+
{marks.map((m) => (
|
|
199
|
+
<span key={`${m.kind}-${m.t}`} title={`${m.label} · ${month(m.t)}`} style={{
|
|
200
|
+
position: 'absolute', left: `${((m.t - clock.min) / (clock.max - clock.min)) * 100}%`,
|
|
201
|
+
width: m.kind === 'job' ? 3 : 2, height: m.kind === 'job' ? 8 : 5, borderRadius: 1, transform: 'translateX(-50%)',
|
|
202
|
+
background: m.kind === 'job' ? '#D4AF37' : '#74B9FF', opacity: m.kind === 'job' ? 0.95 : 0.7,
|
|
203
|
+
}} />
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', ...small }}>
|
|
208
|
+
<span>{month(clock.min)}</span><span>{month(clock.max)}</span>
|
|
209
|
+
</div>
|
|
210
|
+
<div style={{ fontSize: 11, color: '#aab', margin: '8px 0 4px' }}>Length</div>
|
|
211
|
+
<Choice value={lab.speed} options={[[5, '5 s'], [15, '15 s'], [30, '30 s'], [60, '1 min']]} onPick={(v) => setLab({ speed: v })} />
|
|
212
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#aab', cursor: 'pointer' }}>
|
|
213
|
+
<input type="checkbox" checked={lab.loop} onChange={(e) => setLab({ loop: e.target.checked })} style={{ accentColor: '#3498DB' }} />
|
|
214
|
+
Loop
|
|
215
|
+
</label>
|
|
216
|
+
<div style={{ ...small, marginTop: 4 }}>
|
|
217
|
+
Your connections appear on the day you connected; their circles come with them.
|
|
218
|
+
{marks.length > 0 ? ' Gold marks are your job starts and blue ones your posts, from the Social tab.' : ''}
|
|
219
|
+
</div>
|
|
220
|
+
{recordable && (
|
|
221
|
+
<button disabled={!!busy} onClick={() => run('video', recordReplay)} style={{
|
|
222
|
+
marginTop: 8, width: '100%', padding: '6px 10px', borderRadius: 6, fontSize: 11, fontWeight: 600,
|
|
223
|
+
cursor: busy ? 'default' : 'pointer', border: '1px solid rgba(231,76,60,0.5)',
|
|
224
|
+
background: 'rgba(231,76,60,0.12)', color: '#f5b7b1',
|
|
225
|
+
}}>{busy === 'video' ? '● Recording…' : '● Record the replay as a video'}</button>
|
|
226
|
+
)}
|
|
227
|
+
</>
|
|
228
|
+
) : (
|
|
229
|
+
<div style={small}>
|
|
230
|
+
Nothing to replay: the connections on screen have no “connected on” dates. A LinkedIn CSV or a
|
|
231
|
+
network scan has them; the sample network doesn’t.
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
<div style={heading}>Saved layouts</div>
|
|
236
|
+
{layouts.length > 0 && (
|
|
237
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
|
|
238
|
+
{layouts.map((l) => (
|
|
239
|
+
<span key={l.name} style={{ display: 'inline-flex', alignItems: 'center', borderRadius: 12, border: '1px solid rgba(52,152,219,0.4)', background: 'rgba(52,152,219,0.1)' }}>
|
|
240
|
+
<button onClick={() => applyLayout(l.name)} style={{ padding: '3px 4px 3px 9px', border: 'none', background: 'none', color: '#cfe6f7', fontSize: 10.5, cursor: 'pointer' }}>{l.name}</button>
|
|
241
|
+
<button onClick={() => forgetLayout(l.name)} aria-label={`Forget ${l.name}`} title="Forget this layout" style={{ padding: '3px 8px 3px 3px', border: 'none', background: 'none', color: '#667', fontSize: 11, cursor: 'pointer' }}>×</button>
|
|
242
|
+
</span>
|
|
243
|
+
))}
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
<form onSubmit={(e) => { e.preventDefault(); saveLayout(name); setName(''); }} style={{ display: 'flex', gap: 4 }}>
|
|
247
|
+
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name this layout" maxLength={40} style={{
|
|
248
|
+
flex: 1, minWidth: 0, padding: '5px 8px', borderRadius: 6, fontSize: 11,
|
|
249
|
+
border: '1px solid rgba(255,255,255,0.12)', background: 'rgba(0,0,0,0.25)', color: '#dde',
|
|
250
|
+
}} />
|
|
251
|
+
<button type="submit" disabled={!name.trim()} style={{
|
|
252
|
+
padding: '5px 10px', borderRadius: 6, fontSize: 11, fontWeight: 600, cursor: name.trim() ? 'pointer' : 'default',
|
|
253
|
+
border: '1px solid rgba(52,152,219,0.6)', background: 'rgba(52,152,219,0.18)', color: name.trim() ? '#cfe6f7' : '#667',
|
|
254
|
+
}}>Save</button>
|
|
255
|
+
</form>
|
|
256
|
+
|
|
257
|
+
<div style={heading}>Share</div>
|
|
258
|
+
<button disabled={!!busy} onClick={() => run('picture', savePicture)} style={{
|
|
259
|
+
width: '100%', padding: '6px 10px', borderRadius: 6, fontSize: 11, fontWeight: 600, cursor: busy ? 'default' : 'pointer',
|
|
260
|
+
border: '1px solid rgba(52,152,219,0.6)', background: 'rgba(52,152,219,0.18)', color: '#cfe6f7',
|
|
261
|
+
}}>{busy === 'picture' ? 'Saving…' : 'Save a picture of the Galaxy'}</button>
|
|
262
|
+
<div style={{ ...small, marginTop: 4 }}>
|
|
263
|
+
A PNG of what’s on screen, at twice the size. It shows real names, so check it before you post it.
|
|
264
|
+
</div>
|
|
265
|
+
{note && <div style={{ ...small, color: '#e67e73', marginTop: 4 }}>{note}</div>}
|
|
266
|
+
|
|
267
|
+
<button onClick={() => { stopReplay(); setClock({ find: '' }); setLab(null); }} style={{
|
|
268
|
+
marginTop: 10, width: '100%', padding: '6px 10px', borderRadius: 6, fontSize: 11, cursor: 'pointer',
|
|
269
|
+
border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.04)', color: '#aaa',
|
|
270
|
+
}}>Reset to today’s layout</button>
|
|
271
|
+
</>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
@@ -14,6 +14,7 @@ import OnboardingGate from '../components/OnboardingGate';
|
|
|
14
14
|
import { useUser } from '../components/UserProvider';
|
|
15
15
|
import { loadNetwork } from '../../lib/network';
|
|
16
16
|
import { readTable, messageStats, careerChapters, postingEffect, invitationSplit, warmthOf, repliesWaiting } from '../../lib/linkedin-export';
|
|
17
|
+
import { mergeSocial } from '../../lib/galaxy-lab';
|
|
17
18
|
import { keyFor } from '../../lib/separation';
|
|
18
19
|
import { runScrape, watchScanner, scannerNow } from '../../lib/scraper-client';
|
|
19
20
|
import { Body, LINE, FONT } from '../components/ui';
|
|
@@ -119,22 +120,9 @@ function SocialInner() {
|
|
|
119
120
|
reload();
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
// What each person's messages say, the export and the live sync together
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
for (const [url, v] of Object.entries(social?.people || {})) out.set(keyFor({ profile_url: url }), { ...v });
|
|
126
|
-
for (const [url, v] of Object.entries(social?.live || {})) {
|
|
127
|
-
const k = keyFor({ profile_url: url });
|
|
128
|
-
const cur = out.get(k) || { total: 0, recent: 0 };
|
|
129
|
-
if (v.last && (!cur.last || v.last > cur.last)) cur.last = v.last;
|
|
130
|
-
cur.unread = v.unread;
|
|
131
|
-
cur.total = Math.max(cur.total || 0, 1);
|
|
132
|
-
out.set(k, cur);
|
|
133
|
-
}
|
|
134
|
-
return out;
|
|
135
|
-
}, [social]);
|
|
136
|
-
// "Now" for warmth is the newest thing the data knows, not the clock: an older export reads as it was.
|
|
137
|
-
const asOf = Math.max(social?.asOf || 0, social?.liveAt ? Date.parse(social.liveAt) : 0, ...[...merged.values()].map((v) => v.last || 0));
|
|
123
|
+
// What each person's messages say, the export and the live sync together, and
|
|
124
|
+
// "now" for warmth: the newest thing the data knows, so an older export reads as it was.
|
|
125
|
+
const { merged, asOf } = useMemo(() => mergeSocial(social), [social]);
|
|
138
126
|
|
|
139
127
|
const warmth = useMemo(() => {
|
|
140
128
|
const counts = { warm: 0, cool: 0, dormant: 0, never: 0 };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Save the Galaxy as a picture, or record its replay as a video (the physics
|
|
2
|
+
// lab, experimental). The Galaxy registers its <svg> here while it's on screen;
|
|
3
|
+
// the lab's buttons call these. Nothing leaves the computer: the file is saved
|
|
4
|
+
// like any other download.
|
|
5
|
+
|
|
6
|
+
import { clockNow, setClock, play, pause } from './galaxy-lab.js';
|
|
7
|
+
|
|
8
|
+
let current = null; // { svg, stamp: () => text or null, legend: () => [[label, colour]] }
|
|
9
|
+
|
|
10
|
+
export function registerGalaxy(entry) {
|
|
11
|
+
current = entry;
|
|
12
|
+
return () => { if (current === entry) current = null; };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const galaxyShown = () => current != null;
|
|
16
|
+
|
|
17
|
+
// The lab's highlight rules live in the page's CSS, which a picture of the
|
|
18
|
+
// <svg> alone doesn't carry, so they ride along inside it.
|
|
19
|
+
const EXPORT_CSS = `
|
|
20
|
+
.lab-focus .gn:not(.lit), .lab-focus .gl:not(.lit) { opacity: 0.06; }
|
|
21
|
+
.lab-focus .gl.lit { stroke-opacity: 0.7; }
|
|
22
|
+
.lab-focus .dot-rings, .lab-focus .catalyst-ring { opacity: 0.15; }
|
|
23
|
+
.lab-find .gn:not(.found), .lab-find .gl:not(.found) { opacity: 0.07; }
|
|
24
|
+
.lab-find .gn.found { stroke: #fff; stroke-width: 2px; }
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
function svgImage(svg) {
|
|
28
|
+
const clone = svg.cloneNode(true);
|
|
29
|
+
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
30
|
+
// The page's font, which the <svg> inherits on screen but not in a file.
|
|
31
|
+
clone.setAttribute('font-family', getComputedStyle(svg).fontFamily || 'system-ui, sans-serif');
|
|
32
|
+
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
|
33
|
+
style.textContent = EXPORT_CSS;
|
|
34
|
+
clone.insertBefore(style, clone.firstChild);
|
|
35
|
+
const xml = new XMLSerializer().serializeToString(clone);
|
|
36
|
+
const img = new Image();
|
|
37
|
+
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
|
|
38
|
+
return img.decode().then(() => img);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// One frame: the Galaxy, its date while a replay runs, the legend, and a small credit.
|
|
42
|
+
async function drawFrame(ctx, w, h) {
|
|
43
|
+
const img = await svgImage(current.svg);
|
|
44
|
+
ctx.fillStyle = '#0a0a1a';
|
|
45
|
+
ctx.fillRect(0, 0, w, h);
|
|
46
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
47
|
+
const scale = w / current.svg.clientWidth;
|
|
48
|
+
ctx.save();
|
|
49
|
+
ctx.scale(scale, scale);
|
|
50
|
+
const cw = w / scale;
|
|
51
|
+
const ch = h / scale;
|
|
52
|
+
const stamp = current.stamp();
|
|
53
|
+
if (stamp) {
|
|
54
|
+
ctx.font = '600 15px -apple-system, system-ui, sans-serif';
|
|
55
|
+
const tw = ctx.measureText(stamp).width;
|
|
56
|
+
ctx.fillStyle = 'rgba(10,15,30,0.85)';
|
|
57
|
+
ctx.beginPath();
|
|
58
|
+
ctx.roundRect?.(cw / 2 - tw / 2 - 16, ch - 52, tw + 32, 32, 16);
|
|
59
|
+
ctx.fill();
|
|
60
|
+
ctx.fillStyle = '#e6edf5';
|
|
61
|
+
ctx.textAlign = 'center';
|
|
62
|
+
ctx.fillText(stamp, cw / 2, ch - 31);
|
|
63
|
+
}
|
|
64
|
+
ctx.textAlign = 'left';
|
|
65
|
+
ctx.font = '11px -apple-system, system-ui, sans-serif';
|
|
66
|
+
let x = 20;
|
|
67
|
+
for (const [label, colour] of current.legend()) {
|
|
68
|
+
ctx.fillStyle = colour;
|
|
69
|
+
ctx.beginPath();
|
|
70
|
+
ctx.arc(x + 4, ch - 24, 4, 0, Math.PI * 2);
|
|
71
|
+
ctx.fill();
|
|
72
|
+
ctx.fillStyle = '#9aa';
|
|
73
|
+
ctx.fillText(label, x + 12, ch - 20);
|
|
74
|
+
x += 20 + ctx.measureText(label).width;
|
|
75
|
+
}
|
|
76
|
+
ctx.textAlign = 'right';
|
|
77
|
+
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
|
78
|
+
ctx.fillText('Six Degrees', cw - 16, 24);
|
|
79
|
+
ctx.restore();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function save(blob, name) {
|
|
83
|
+
const url = URL.createObjectURL(blob);
|
|
84
|
+
const a = document.createElement('a');
|
|
85
|
+
a.href = url;
|
|
86
|
+
a.download = name;
|
|
87
|
+
// Anything appended to <body> is position: fixed (TRAPS §29). Gone at once.
|
|
88
|
+
a.style.position = 'fixed';
|
|
89
|
+
a.style.left = '-9999px';
|
|
90
|
+
document.body.appendChild(a);
|
|
91
|
+
a.click();
|
|
92
|
+
a.remove();
|
|
93
|
+
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
97
|
+
|
|
98
|
+
function canvasFor(scale) {
|
|
99
|
+
const w = Math.round(current.svg.clientWidth * scale);
|
|
100
|
+
const h = Math.round(current.svg.clientHeight * scale);
|
|
101
|
+
const canvas = document.createElement('canvas');
|
|
102
|
+
canvas.width = w;
|
|
103
|
+
canvas.height = h;
|
|
104
|
+
return { canvas, ctx: canvas.getContext('2d'), w, h };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The Galaxy as it is on screen, at twice the size, as a PNG. */
|
|
108
|
+
export async function savePicture() {
|
|
109
|
+
if (!current) throw new Error('Open the Galaxy first.');
|
|
110
|
+
const { canvas, ctx, w, h } = canvasFor(2);
|
|
111
|
+
await drawFrame(ctx, w, h);
|
|
112
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
|
113
|
+
save(blob, `six-degrees-galaxy-${today()}.png`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const canRecord = () => typeof window !== 'undefined' && typeof window.MediaRecorder !== 'undefined'
|
|
117
|
+
&& typeof HTMLCanvasElement !== 'undefined' && typeof HTMLCanvasElement.prototype.captureStream === 'function';
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Plays the replay from the start and records it: a video the length of the
|
|
121
|
+
* replay, at 12 frames a second. Resolves when it's saved.
|
|
122
|
+
*/
|
|
123
|
+
export async function recordReplay() {
|
|
124
|
+
if (!current) throw new Error('Open the Galaxy first.');
|
|
125
|
+
if (!canRecord()) throw new Error('This window can’t record video.');
|
|
126
|
+
const { canvas, ctx, w, h } = canvasFor(1);
|
|
127
|
+
const type = ['video/mp4;codecs=avc1', 'video/webm;codecs=vp9', 'video/webm', 'video/mp4'].find((t) => MediaRecorder.isTypeSupported(t));
|
|
128
|
+
if (!type) throw new Error('This window can’t record video.');
|
|
129
|
+
await drawFrame(ctx, w, h);
|
|
130
|
+
const recorder = new MediaRecorder(canvas.captureStream(12), { mimeType: type, videoBitsPerSecond: 6_000_000 });
|
|
131
|
+
const chunks = [];
|
|
132
|
+
recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
|
|
133
|
+
const done = new Promise((resolve) => { recorder.onstop = resolve; });
|
|
134
|
+
recorder.start(250);
|
|
135
|
+
pause();
|
|
136
|
+
setClock({ at: clockNow().min });
|
|
137
|
+
play();
|
|
138
|
+
let drawing = false;
|
|
139
|
+
await new Promise((resolve) => {
|
|
140
|
+
const tick = setInterval(async () => {
|
|
141
|
+
if (drawing) return;
|
|
142
|
+
drawing = true;
|
|
143
|
+
try { await drawFrame(ctx, w, h); } finally { drawing = false; }
|
|
144
|
+
if (!clockNow().playing) { clearInterval(tick); setTimeout(resolve, 600); }
|
|
145
|
+
}, 1000 / 12);
|
|
146
|
+
});
|
|
147
|
+
await drawFrame(ctx, w, h);
|
|
148
|
+
recorder.stop();
|
|
149
|
+
await done;
|
|
150
|
+
const ext = type.startsWith('video/mp4') ? 'mp4' : 'webm';
|
|
151
|
+
save(new Blob(chunks, { type: type.split(';')[0] }), `six-degrees-replay-${today()}.${ext}`);
|
|
152
|
+
}
|