birdclaw 0.8.2 → 0.8.4

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.
Files changed (247) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +10 -8
  3. package/bin/birdclaw.mjs +4 -66
  4. package/dist/cli/birdclaw.js +28165 -0
  5. package/dist/client/assets/AvatarChip-DLoqM5xD.js +1 -0
  6. package/dist/client/assets/ProfilePreview-BejAuYsJ.js +1 -0
  7. package/dist/client/assets/SavedTimelineView-BIS7HKpp.js +1 -0
  8. package/dist/client/assets/SmartTimestamp-BR0-9se2.js +1 -0
  9. package/dist/client/assets/TimelineRouteFrame-CyGPj0eM.js +1 -0
  10. package/dist/client/assets/blocks-7UDKQp8H.js +1 -0
  11. package/dist/client/assets/bookmarks-CbBkMvXk.js +1 -0
  12. package/dist/client/assets/chunk-C0phanGV.js +1 -0
  13. package/dist/client/assets/circle-check-C_Vq3ElD.js +1 -0
  14. package/dist/client/assets/client-stream-contracts-ys3XhWG4.js +5 -0
  15. package/dist/client/assets/createLucideIcon-JV8isFFR.js +1 -0
  16. package/dist/client/assets/data-sources-Cm2bpNvg.js +1 -0
  17. package/dist/client/assets/discuss-DTjaOugw.js +1 -0
  18. package/dist/client/assets/dms-CGd6Eogq.js +1 -0
  19. package/dist/client/assets/inbox-BHlGuOy2.js +1 -0
  20. package/dist/client/assets/index-CEIG7S6s.js +31 -0
  21. package/dist/client/assets/index-bpFrQ5yG.css +1 -0
  22. package/dist/client/assets/likes-cLrcAQem.js +1 -0
  23. package/dist/client/assets/links-CM9QO1LC.js +1 -0
  24. package/dist/client/assets/mapbox-BxTlqXt9.js +2 -0
  25. package/dist/client/assets/mapbox-gl-dgf_hX3a.js +3215 -0
  26. package/dist/client/assets/mentions-CxKCgzIf.js +1 -0
  27. package/dist/client/assets/network-map-B5a3whqj.js +2 -0
  28. package/dist/client/assets/profile-analyze-CRtNEwlb.js +1 -0
  29. package/dist/client/assets/profile-hydration-client-YEtJsNTv.js +1 -0
  30. package/dist/client/assets/profiles._handle-C82_HoKX.js +1 -0
  31. package/dist/client/assets/query-client-fYu2rzRC.js +17 -0
  32. package/dist/client/assets/rate-limits-ScaEwdVK.js +1 -0
  33. package/dist/client/assets/react-dom-DHl6Ug7F.js +1 -0
  34. package/dist/client/assets/react-nBylq1U5.js +1 -0
  35. package/dist/client/assets/repeat-2-xCwHM9hD.js +1 -0
  36. package/dist/client/assets/routes-q7ZaW4En.js +1 -0
  37. package/dist/client/assets/schemas-I9r6F6ya.js +64 -0
  38. package/dist/client/assets/search-Dq5kYsxA.js +1 -0
  39. package/dist/client/assets/sparkles-CQVlvHsZ.js +1 -0
  40. package/dist/client/assets/styles-B8s3tt3r.css +2 -0
  41. package/dist/client/assets/today-CeG1O7Py.js +1 -0
  42. package/dist/client/assets/triangle-alert-CBL7s2pf.js +1 -0
  43. package/dist/client/assets/useTimelineRouteData-1HsqgvUi.js +1 -0
  44. package/dist/client/assets/users-CHbKlqUr.js +1 -0
  45. package/dist/server/assets/AvatarChip-zUDAEmjL.js +105 -0
  46. package/dist/server/assets/FeedState-ntW0Q2GB.js +214 -0
  47. package/dist/server/assets/ProfileAnalysisStream-BFvhVdhn.js +198 -0
  48. package/dist/server/assets/ProfilePreview-C6gAor_j.js +752 -0
  49. package/dist/server/assets/SavedTimelineView-BG1jnBp1.js +64 -0
  50. package/dist/server/assets/SmartTimestamp-DfwE1NGf.js +45 -0
  51. package/dist/server/assets/SyncNowButton-BOn_IBXS.js +76 -0
  52. package/dist/server/assets/TimelineRouteFrame-CU7ilM7U.js +85 -0
  53. package/dist/server/assets/TweetRichText-DhCVv1-6.js +74 -0
  54. package/dist/server/assets/_tanstack-start-manifest_v-C-ZRnG_n.js +148 -0
  55. package/dist/server/assets/account-selection-CTdFGWRk.js +44 -0
  56. package/dist/server/assets/blocks-C1mgGTB3.js +299 -0
  57. package/dist/server/assets/blocks-DSgQghqP.js +17 -0
  58. package/dist/server/assets/bookmarks-BJN8tykY.js +14 -0
  59. package/dist/server/assets/client-stream-contracts-C2xbWVjm.js +769 -0
  60. package/dist/server/assets/data-sources-BnA1Q94p.js +180 -0
  61. package/dist/server/assets/discuss-CJ1dau1j.js +15 -0
  62. package/dist/server/assets/discuss-QTo08NCN.js +338 -0
  63. package/dist/server/assets/dms-BuLj4bp8.js +368 -0
  64. package/dist/server/assets/dms-DAyQrVM2.js +240 -0
  65. package/dist/server/assets/empty-plugin-adapters-DzrEkdBF.js +5 -0
  66. package/dist/server/assets/inbox-CkGOaW1q.js +214 -0
  67. package/dist/server/assets/inbox-DpWd1EzC.js +146 -0
  68. package/dist/server/assets/likes-DBOAtsJU.js +14 -0
  69. package/dist/server/assets/links-DAhSjynu.js +240 -0
  70. package/dist/server/assets/links-DmH5lxHO.js +468 -0
  71. package/dist/server/assets/mentions-BH2MzKtB.js +26 -0
  72. package/dist/server/assets/network-map-D3CzRcC4.js +224 -0
  73. package/dist/server/assets/network-map-D5DoLOwR.js +582 -0
  74. package/dist/server/assets/profile-analyze-BP65PED0.js +90 -0
  75. package/dist/server/assets/profile-analyze-C1NoM0tV.js +9 -0
  76. package/dist/server/assets/profile-hydration-client-GJ4kwJS8.js +133 -0
  77. package/dist/server/assets/profiles._handle-DjwlN_ac.js +184 -0
  78. package/dist/server/assets/profiles._handle-DsLNj2Ca.js +12 -0
  79. package/dist/server/assets/query-client-BdauHBgd.js +923 -0
  80. package/dist/server/assets/rate-limits-VAr2qzYD.js +262 -0
  81. package/dist/server/assets/route-search-BUlQ5lGM.js +117 -0
  82. package/dist/server/assets/router-DqkwSPiM.js +17026 -0
  83. package/dist/server/assets/routes-A9c9ZhwN.js +26 -0
  84. package/dist/server/assets/start-FaDOTmto.js +4 -0
  85. package/dist/server/assets/today-B5hVM57Z.js +17 -0
  86. package/dist/server/assets/today-fvGiZ7ud.js +303 -0
  87. package/dist/server/assets/ui-qbmcMSSX.js +137 -0
  88. package/{src/components/useDebouncedValue.ts → dist/server/assets/useDebouncedValue-DOnA6Xcy.js} +4 -4
  89. package/dist/server/assets/useTimelineRouteData-27cxynxa.js +1101 -0
  90. package/dist/server/server.js +5520 -0
  91. package/package.json +27 -34
  92. package/playwright.config.ts +0 -35
  93. package/scripts/browser-perf.mjs +0 -427
  94. package/scripts/build-docs-site.mjs +0 -967
  95. package/scripts/docs-site-assets.mjs +0 -311
  96. package/scripts/run-vitest.mjs +0 -21
  97. package/scripts/sanitize-node-options.mjs +0 -23
  98. package/scripts/start-test-server.mjs +0 -42
  99. package/src/cli-moderation.ts +0 -210
  100. package/src/cli.ts +0 -2336
  101. package/src/components/AccountSwitcher.tsx +0 -186
  102. package/src/components/AppNav.tsx +0 -118
  103. package/src/components/AvatarChip.tsx +0 -58
  104. package/src/components/BrandMark.tsx +0 -67
  105. package/src/components/ConversationThread.tsx +0 -131
  106. package/src/components/DmWorkspace.tsx +0 -286
  107. package/src/components/EmbeddedTweetCard.tsx +0 -55
  108. package/src/components/FeedState.tsx +0 -147
  109. package/src/components/FloatingPreview.tsx +0 -382
  110. package/src/components/InboxCard.tsx +0 -152
  111. package/src/components/LinkPreviewCard.tsx +0 -292
  112. package/src/components/MarkdownViewer.tsx +0 -777
  113. package/src/components/ProfileAnalysisStream.tsx +0 -428
  114. package/src/components/ProfilePreview.tsx +0 -129
  115. package/src/components/SavedTimelineView.tsx +0 -155
  116. package/src/components/SmartTimestamp.tsx +0 -72
  117. package/src/components/SyncNowButton.tsx +0 -140
  118. package/src/components/ThemeSlider.tsx +0 -80
  119. package/src/components/TimelineCard.tsx +0 -533
  120. package/src/components/TimelineRouteFrame.tsx +0 -186
  121. package/src/components/TweetArticleCard.tsx +0 -66
  122. package/src/components/TweetMediaGrid.tsx +0 -185
  123. package/src/components/TweetRichText.tsx +0 -159
  124. package/src/components/account-selection.ts +0 -64
  125. package/src/components/useTimelineRouteData.ts +0 -323
  126. package/src/lib/account-sync-job.ts +0 -666
  127. package/src/lib/actions-transport.ts +0 -300
  128. package/src/lib/api-client.ts +0 -419
  129. package/src/lib/archive-finder.ts +0 -185
  130. package/src/lib/archive-import.ts +0 -2764
  131. package/src/lib/authored-live.ts +0 -1072
  132. package/src/lib/avatar-cache.ts +0 -349
  133. package/src/lib/backup.ts +0 -2741
  134. package/src/lib/bird-actions.ts +0 -294
  135. package/src/lib/bird-command.ts +0 -130
  136. package/src/lib/bird.ts +0 -1140
  137. package/src/lib/blocklist.ts +0 -136
  138. package/src/lib/blocks-write.ts +0 -167
  139. package/src/lib/blocks.ts +0 -389
  140. package/src/lib/bookmark-sync-job.ts +0 -536
  141. package/src/lib/client-cache.ts +0 -109
  142. package/src/lib/config.ts +0 -224
  143. package/src/lib/conversation-surface.ts +0 -205
  144. package/src/lib/data-sources.ts +0 -219
  145. package/src/lib/db.ts +0 -1135
  146. package/src/lib/dms-live.ts +0 -933
  147. package/src/lib/effect-runtime.ts +0 -45
  148. package/src/lib/follow-graph.ts +0 -1097
  149. package/src/lib/geocoding.ts +0 -296
  150. package/src/lib/http-effect.ts +0 -222
  151. package/src/lib/identity-search-index.ts +0 -369
  152. package/src/lib/inbox.ts +0 -241
  153. package/src/lib/link-index.ts +0 -641
  154. package/src/lib/link-insights.ts +0 -860
  155. package/src/lib/link-preview-metadata.ts +0 -754
  156. package/src/lib/location.ts +0 -137
  157. package/src/lib/media-fetch.ts +0 -860
  158. package/src/lib/media-includes.ts +0 -165
  159. package/src/lib/mention-threads-live.ts +0 -917
  160. package/src/lib/mentions-export.ts +0 -147
  161. package/src/lib/mentions-live.ts +0 -1238
  162. package/src/lib/moderation-target.ts +0 -216
  163. package/src/lib/moderation-write.ts +0 -131
  164. package/src/lib/mutes-write.ts +0 -167
  165. package/src/lib/mutes.ts +0 -84
  166. package/src/lib/ndjson-stream.ts +0 -106
  167. package/src/lib/network-map.ts +0 -382
  168. package/src/lib/openai.ts +0 -117
  169. package/src/lib/period-digest.ts +0 -1552
  170. package/src/lib/present.ts +0 -118
  171. package/src/lib/profile-affiliation-hydration.ts +0 -228
  172. package/src/lib/profile-affiliations.ts +0 -262
  173. package/src/lib/profile-analysis.ts +0 -1272
  174. package/src/lib/profile-bio-entities.ts +0 -318
  175. package/src/lib/profile-history.ts +0 -164
  176. package/src/lib/profile-hydration.ts +0 -180
  177. package/src/lib/profile-replies.ts +0 -77
  178. package/src/lib/profile-resolver.ts +0 -650
  179. package/src/lib/queries.ts +0 -2381
  180. package/src/lib/research.ts +0 -652
  181. package/src/lib/search-discussion.ts +0 -1016
  182. package/src/lib/seed.ts +0 -613
  183. package/src/lib/sqlite.ts +0 -147
  184. package/src/lib/sync-cache.ts +0 -65
  185. package/src/lib/theme-transition.ts +0 -113
  186. package/src/lib/theme.tsx +0 -165
  187. package/src/lib/timeline-collections-live.ts +0 -581
  188. package/src/lib/timeline-live.ts +0 -452
  189. package/src/lib/tweet-account-edges.ts +0 -48
  190. package/src/lib/tweet-lookup.ts +0 -46
  191. package/src/lib/tweet-render.ts +0 -430
  192. package/src/lib/tweet-search-live.ts +0 -566
  193. package/src/lib/types.ts +0 -723
  194. package/src/lib/ui.ts +0 -529
  195. package/src/lib/url-expansion-store.ts +0 -110
  196. package/src/lib/url-expansion.ts +0 -343
  197. package/src/lib/url-safety.ts +0 -220
  198. package/src/lib/web-sync.ts +0 -516
  199. package/src/lib/whois.ts +0 -1079
  200. package/src/lib/x-profile.ts +0 -365
  201. package/src/lib/x-web.ts +0 -199
  202. package/src/lib/xurl-rate-limits.ts +0 -267
  203. package/src/lib/xurl.ts +0 -1677
  204. package/src/routeTree.gen.ts +0 -723
  205. package/src/router.tsx +0 -20
  206. package/src/routes/__root.tsx +0 -79
  207. package/src/routes/api/action.tsx +0 -147
  208. package/src/routes/api/avatar.tsx +0 -50
  209. package/src/routes/api/blocks.tsx +0 -35
  210. package/src/routes/api/conversation.tsx +0 -45
  211. package/src/routes/api/data-sources.tsx +0 -24
  212. package/src/routes/api/inbox.tsx +0 -43
  213. package/src/routes/api/link-insights.tsx +0 -85
  214. package/src/routes/api/link-preview.tsx +0 -47
  215. package/src/routes/api/network-map.tsx +0 -55
  216. package/src/routes/api/period-digest.tsx +0 -101
  217. package/src/routes/api/profile-analysis.tsx +0 -97
  218. package/src/routes/api/profile-hydrate.tsx +0 -54
  219. package/src/routes/api/query.tsx +0 -115
  220. package/src/routes/api/search-discussion.tsx +0 -113
  221. package/src/routes/api/status.tsx +0 -28
  222. package/src/routes/api/sync.tsx +0 -105
  223. package/src/routes/api/xurl-rate-limits.tsx +0 -24
  224. package/src/routes/blocks.tsx +0 -377
  225. package/src/routes/bookmarks.tsx +0 -18
  226. package/src/routes/data-sources.tsx +0 -257
  227. package/src/routes/discuss.tsx +0 -419
  228. package/src/routes/dms.tsx +0 -432
  229. package/src/routes/inbox.tsx +0 -226
  230. package/src/routes/index.tsx +0 -32
  231. package/src/routes/likes.tsx +0 -18
  232. package/src/routes/links.tsx +0 -994
  233. package/src/routes/mentions.tsx +0 -32
  234. package/src/routes/network-map.tsx +0 -1035
  235. package/src/routes/profile-analyze.tsx +0 -112
  236. package/src/routes/profiles.$handle.tsx +0 -228
  237. package/src/routes/rate-limits.tsx +0 -309
  238. package/src/routes/today.tsx +0 -511
  239. package/src/styles.css +0 -257
  240. package/tsconfig.json +0 -29
  241. package/vite.config.ts +0 -29
  242. /package/{public → dist/client}/birdclaw-mark.png +0 -0
  243. /package/{public → dist/client}/favicon.ico +0 -0
  244. /package/{public → dist/client}/logo192.png +0 -0
  245. /package/{public → dist/client}/logo512.png +0 -0
  246. /package/{public → dist/client}/manifest.json +0 -0
  247. /package/{public → dist/client}/robots.txt +0 -0
@@ -1,1035 +0,0 @@
1
- import { createFileRoute } from "@tanstack/react-router";
2
- import {
3
- Globe2,
4
- MapPin,
5
- RefreshCw,
6
- Search,
7
- Users,
8
- WifiOff,
9
- } from "lucide-react";
10
- import {
11
- type CSSProperties,
12
- useCallback,
13
- useEffect,
14
- useMemo,
15
- useRef,
16
- useState,
17
- } from "react";
18
- import "mapbox-gl/dist/mapbox-gl.css";
19
- import type * as GeoJSON from "geojson";
20
- import Supercluster from "supercluster";
21
- import type { MapRef } from "react-map-gl/mapbox";
22
- import { useSelectedAccountId } from "#/components/account-selection";
23
- import { fetchQueryEnvelope } from "#/lib/api-client";
24
- import type { NetworkMapKind, NetworkMapResponse } from "#/lib/network-map";
25
- import type { QueryEnvelope } from "#/lib/types";
26
- import {
27
- cx,
28
- errorCopyClass,
29
- pageHeaderActionsClass,
30
- pageHeaderClass,
31
- pageHeaderRowClass,
32
- pageSubtitleClass,
33
- pageTitleClass,
34
- secondaryButtonClass,
35
- segmentedClass,
36
- segmentActiveClass,
37
- segmentClass,
38
- searchFieldIconClass,
39
- searchFieldInputClass,
40
- searchFieldShellClass,
41
- statusCopyClass,
42
- } from "#/lib/ui";
43
-
44
- export const Route = createFileRoute("/network-map")({
45
- component: NetworkMapRoute,
46
- });
47
-
48
- type ReactMapboxModule = typeof import("react-map-gl/mapbox");
49
- type MapFeature = NetworkMapResponse["features"][number];
50
- type MapBounds = [number, number, number, number];
51
-
52
- interface ClusterPointProperties {
53
- featureIndex: number;
54
- handle: string;
55
- name: string;
56
- avatarUrl: string | null;
57
- relationship: MapFeature["properties"]["relationship"];
58
- followersCount: number;
59
- }
60
-
61
- interface ClusterAggregateProperties {
62
- followers: number;
63
- following: number;
64
- mutual: number;
65
- }
66
-
67
- interface ClusterFeatureProperties extends ClusterAggregateProperties {
68
- cluster: true;
69
- cluster_id: number;
70
- point_count: number;
71
- point_count_abbreviated: string | number;
72
- }
73
-
74
- type ClusterPointFeature = GeoJSON.Feature<
75
- GeoJSON.Point,
76
- ClusterPointProperties
77
- >;
78
- type ClusterFeature = GeoJSON.Feature<GeoJSON.Point, ClusterFeatureProperties>;
79
- type ClusterResult = ClusterPointFeature | ClusterFeature;
80
- type MapViewport = { bounds: MapBounds; zoom: number };
81
- type MapTarget = {
82
- getBounds: () => {
83
- getWest: () => number;
84
- getSouth: () => number;
85
- getEast: () => number;
86
- getNorth: () => number;
87
- };
88
- getZoom: () => number;
89
- };
90
-
91
- type SelectedOverlay =
92
- | { kind: "profile"; feature: MapFeature }
93
- | {
94
- kind: "cluster";
95
- coordinates: [number, number];
96
- count: number;
97
- features: MapFeature[];
98
- stats: ClusterAggregateProperties;
99
- };
100
-
101
- const CLUSTER_LEAF_SAMPLE_SIZE = 48;
102
-
103
- const MAP_TYPES: Array<{ value: NetworkMapKind; label: string }> = [
104
- { value: "all", label: "All" },
105
- { value: "followers", label: "Followers" },
106
- { value: "following", label: "Following" },
107
- { value: "mutual", label: "Mutual" },
108
- ];
109
-
110
- const WORLD_BOUNDS: MapBounds = [-180, -85, 180, 85];
111
- const WORLD_VIEWPORT: MapViewport = { bounds: WORLD_BOUNDS, zoom: 1.15 };
112
-
113
- async function fetchMap(
114
- type: NetworkMapKind,
115
- refresh: boolean,
116
- accountId?: string,
117
- signal?: AbortSignal,
118
- ) {
119
- const url = new URL("/api/network-map", window.location.origin);
120
- url.searchParams.set("type", type);
121
- url.searchParams.set("limit", "50000");
122
- url.searchParams.set("geocodeLimit", refresh ? "80" : "12");
123
- if (accountId) url.searchParams.set("account", accountId);
124
- if (refresh) url.searchParams.set("refresh", "true");
125
- const response = await fetch(url, { signal });
126
- if (!response.ok) {
127
- throw new Error(`Map request failed (${String(response.status)})`);
128
- }
129
- return (await response.json()) as NetworkMapResponse;
130
- }
131
-
132
- function formatNumber(value: number) {
133
- return new Intl.NumberFormat().format(value);
134
- }
135
-
136
- function formatRelationship(value: MapFeature["properties"]["relationship"]) {
137
- if (value === "mutual") return "mutual";
138
- if (value === "following") return "following";
139
- return "follower";
140
- }
141
-
142
- function StatTile({
143
- icon: Icon,
144
- label,
145
- value,
146
- }: {
147
- icon: typeof Users;
148
- label: string;
149
- value: number;
150
- }) {
151
- return (
152
- <div className="flex min-w-0 flex-col gap-2 border-b border-[var(--line)] px-4 py-3 sm:border-r">
153
- <div className="flex items-center gap-2 text-[13px] font-medium text-[var(--ink-soft)]">
154
- <Icon className="size-4" strokeWidth={1.8} />
155
- <span>{label}</span>
156
- </div>
157
- <div className="truncate text-[24px] font-bold tracking-tight text-[var(--ink)]">
158
- {formatNumber(value)}
159
- </div>
160
- </div>
161
- );
162
- }
163
-
164
- function useMapboxModule() {
165
- const [module, setModule] = useState<ReactMapboxModule | null>(null);
166
- const [error, setError] = useState<string | null>(null);
167
-
168
- useEffect(() => {
169
- let cancelled = false;
170
- import("react-map-gl/mapbox")
171
- .then((loaded) => {
172
- if (!cancelled) setModule(loaded);
173
- })
174
- .catch((cause: unknown) => {
175
- if (!cancelled) {
176
- setError(cause instanceof Error ? cause.message : "Map failed");
177
- }
178
- });
179
- return () => {
180
- cancelled = true;
181
- };
182
- }, []);
183
-
184
- return { module, error };
185
- }
186
-
187
- function relationshipColor(
188
- relationship: MapFeature["properties"]["relationship"],
189
- ) {
190
- if (relationship === "mutual") return "#22c55e";
191
- if (relationship === "following") return "#f59e0b";
192
- return "#1d9bf0";
193
- }
194
-
195
- function avatarInitial(feature: MapFeature) {
196
- return (feature.properties.name || feature.properties.handle || "?")
197
- .slice(0, 1)
198
- .toUpperCase();
199
- }
200
-
201
- function avatarPath(feature: MapFeature) {
202
- if (!feature.properties.avatarUrl) return null;
203
- const query = new URLSearchParams({
204
- profileId: feature.properties.profileId,
205
- v: feature.properties.avatarUrl,
206
- });
207
- return `/api/avatar?${query.toString()}`;
208
- }
209
-
210
- function Avatar({
211
- feature,
212
- size = 36,
213
- className,
214
- style,
215
- }: {
216
- feature: MapFeature;
217
- size?: number;
218
- className?: string;
219
- style?: CSSProperties;
220
- }) {
221
- const src = avatarPath(feature);
222
- const [failedSrc, setFailedSrc] = useState<string | null>(null);
223
- const showImage = src && failedSrc !== src;
224
- return (
225
- <div
226
- className={cx(
227
- "relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-[var(--accent-soft)] text-[12px] font-bold text-[var(--accent)] ring-2 ring-white",
228
- className,
229
- )}
230
- style={{ width: size, height: size, ...style }}
231
- >
232
- {showImage ? (
233
- <img
234
- src={src}
235
- alt=""
236
- className="h-full w-full object-cover"
237
- loading="lazy"
238
- onError={() => setFailedSrc(src)}
239
- />
240
- ) : (
241
- <span>{avatarInitial(feature)}</span>
242
- )}
243
- </div>
244
- );
245
- }
246
-
247
- function clusterGradient(stats: ClusterAggregateProperties) {
248
- const total = Math.max(1, stats.followers + stats.following + stats.mutual);
249
- const mutual = (stats.mutual / total) * 100;
250
- const following = mutual + (stats.following / total) * 100;
251
- return `conic-gradient(#22c55e 0 ${mutual}%, #f59e0b ${mutual}% ${following}%, #1d9bf0 ${following}% 100%)`;
252
- }
253
-
254
- function buildClusterIndex(features: MapFeature[]) {
255
- const points: ClusterPointFeature[] = features.map(
256
- (feature, featureIndex) => ({
257
- type: "Feature",
258
- geometry: feature.geometry,
259
- properties: {
260
- featureIndex,
261
- handle: feature.properties.handle,
262
- name: feature.properties.name,
263
- avatarUrl: feature.properties.avatarUrl,
264
- relationship: feature.properties.relationship,
265
- followersCount: feature.properties.followersCount,
266
- },
267
- }),
268
- );
269
- return new Supercluster<ClusterPointProperties, ClusterAggregateProperties>({
270
- maxZoom: 18,
271
- minPoints: 2,
272
- radius: 64,
273
- map: (props) => ({
274
- followers: props.relationship === "followers" ? 1 : 0,
275
- following: props.relationship === "following" ? 1 : 0,
276
- mutual: props.relationship === "mutual" ? 1 : 0,
277
- }),
278
- reduce: (accumulated, props) => {
279
- accumulated.followers += props.followers;
280
- accumulated.following += props.following;
281
- accumulated.mutual += props.mutual;
282
- },
283
- }).load(points);
284
- }
285
-
286
- function isCluster(item: ClusterResult): item is ClusterFeature {
287
- return "cluster" in item.properties && item.properties.cluster === true;
288
- }
289
-
290
- function compareClusterFeatures(a: MapFeature, b: MapFeature) {
291
- return (
292
- b.properties.followersCount - a.properties.followersCount ||
293
- a.properties.handle.localeCompare(b.properties.handle)
294
- );
295
- }
296
-
297
- function getClusterDisplayAnchor(
298
- features: MapFeature[],
299
- fallback: [number, number],
300
- ): [number, number] {
301
- const buckets = new Map<
302
- string,
303
- { coordinates: [number, number]; count: number; followers: number }
304
- >();
305
- for (const feature of features) {
306
- const [lng, lat] = feature.geometry.coordinates;
307
- const key = `${lng.toFixed(4)},${lat.toFixed(4)}`;
308
- const existing = buckets.get(key);
309
- if (existing) {
310
- existing.count += 1;
311
- existing.followers += feature.properties.followersCount;
312
- } else {
313
- buckets.set(key, {
314
- coordinates: [lng, lat],
315
- count: 1,
316
- followers: feature.properties.followersCount,
317
- });
318
- }
319
- }
320
- const best = [...buckets.values()].sort(
321
- (a, b) => b.count - a.count || b.followers - a.followers,
322
- )[0];
323
- return best?.coordinates ?? fallback;
324
- }
325
-
326
- function readViewport(target: unknown): MapViewport | null {
327
- if (!target || typeof target !== "object") return null;
328
- const map = target as Partial<MapTarget>;
329
- if (
330
- typeof map.getBounds !== "function" ||
331
- typeof map.getZoom !== "function"
332
- ) {
333
- return null;
334
- }
335
- const bounds = map.getBounds();
336
- return {
337
- bounds: [
338
- bounds.getWest(),
339
- bounds.getSouth(),
340
- bounds.getEast(),
341
- bounds.getNorth(),
342
- ],
343
- zoom: map.getZoom(),
344
- };
345
- }
346
-
347
- function boundsContainFeature(bounds: MapBounds, feature: MapFeature) {
348
- const [west, south, east, north] = bounds;
349
- const [lng, lat] = feature.geometry.coordinates;
350
- const normalizedWest = ((west + 540) % 360) - 180;
351
- const normalizedEast = ((east + 540) % 360) - 180;
352
- const inLatitude = lat >= Math.max(-85, south) && lat <= Math.min(85, north);
353
- const inLongitude =
354
- east - west >= 360
355
- ? true
356
- : normalizedWest <= normalizedEast
357
- ? lng >= normalizedWest && lng <= normalizedEast
358
- : lng >= normalizedWest || lng <= normalizedEast;
359
- return inLatitude && inLongitude;
360
- }
361
-
362
- function featureMatchesSearch(feature: MapFeature, search: string) {
363
- const needle = search.trim().toLowerCase();
364
- if (!needle) return true;
365
- return [
366
- feature.properties.name,
367
- feature.properties.handle,
368
- feature.properties.location,
369
- feature.properties.resolvedLocation ?? "",
370
- formatRelationship(feature.properties.relationship),
371
- ]
372
- .join(" ")
373
- .toLowerCase()
374
- .includes(needle);
375
- }
376
-
377
- function ProfileMarker({ feature }: { feature: MapFeature }) {
378
- const color = relationshipColor(feature.properties.relationship);
379
- return (
380
- <button
381
- type="button"
382
- className="group relative grid size-11 place-items-center rounded-full border-0 bg-transparent p-0 transition-transform hover:z-10 hover:scale-110"
383
- aria-label={`Open @${feature.properties.handle}`}
384
- title={`@${feature.properties.handle}`}
385
- >
386
- <span
387
- className="absolute inset-0 rounded-full opacity-90 shadow-[0_10px_28px_rgba(15,20,25,0.28)]"
388
- style={{ backgroundColor: color }}
389
- />
390
- <Avatar feature={feature} size={34} className="relative ring-[3px]" />
391
- </button>
392
- );
393
- }
394
-
395
- function ClusterMarker({
396
- count,
397
- features,
398
- stats,
399
- }: {
400
- count: number;
401
- features: MapFeature[];
402
- stats: ClusterAggregateProperties;
403
- }) {
404
- const size = Math.min(82, Math.max(52, 44 + Math.log2(count) * 7));
405
- const avatarSize = size >= 72 ? 30 : 25;
406
- return (
407
- <button
408
- type="button"
409
- className="group relative rounded-full border-0 bg-transparent p-0 transition-transform hover:z-10 hover:scale-105"
410
- aria-label={`${formatNumber(count)} located profiles`}
411
- title={`${formatNumber(count)} profiles`}
412
- style={{ width: size, height: size }}
413
- >
414
- <span
415
- className="absolute inset-0 rounded-full opacity-95 shadow-[0_18px_42px_rgba(15,20,25,0.32)]"
416
- style={{ background: clusterGradient(stats) }}
417
- />
418
- <span className="absolute inset-[4px] rounded-full bg-white/88 backdrop-blur-sm" />
419
- <span className="absolute inset-0 flex items-center justify-center">
420
- {features.slice(0, 4).map((feature, index) => (
421
- <Avatar
422
- key={feature.properties.profileId}
423
- feature={feature}
424
- size={avatarSize}
425
- className="absolute"
426
- style={{
427
- transform: `translate(${[-10, 10, -7, 8][index] ?? 0}px, ${[-9, -8, 11, 10][index] ?? 0}px)`,
428
- }}
429
- />
430
- ))}
431
- </span>
432
- <span className="absolute -right-1 -top-1 min-w-7 rounded-full bg-[#0f1419] px-1.5 py-0.5 text-center text-[11px] font-bold leading-5 text-white ring-2 ring-white">
433
- {count > 99 ? "99+" : formatNumber(count)}
434
- </span>
435
- </button>
436
- );
437
- }
438
-
439
- function MapboxPanel({
440
- data,
441
- onViewportChange,
442
- }: {
443
- data: NetworkMapResponse;
444
- onViewportChange: (viewport: MapViewport) => void;
445
- }) {
446
- const { module, error } = useMapboxModule();
447
- const mapRef = useRef<MapRef | null>(null);
448
- const [viewport, setViewport] = useState<MapViewport>(WORLD_VIEWPORT);
449
- const [selected, setSelected] = useState<SelectedOverlay | null>(null);
450
-
451
- const clusterIndex = useMemo(() => buildClusterIndex(data.features), [data]);
452
- const visibleClusters = useMemo(
453
- () =>
454
- clusterIndex.getClusters(
455
- viewport.bounds,
456
- Math.max(0, Math.floor(viewport.zoom)),
457
- ) as ClusterResult[],
458
- [clusterIndex, viewport],
459
- );
460
-
461
- useEffect(() => {
462
- setSelected(null);
463
- }, [data]);
464
-
465
- const updateViewport = useCallback(
466
- (event: { target: unknown }) => {
467
- const next = readViewport(event.target);
468
- if (!next) return;
469
- setViewport(next);
470
- onViewportChange(next);
471
- },
472
- [onViewportChange],
473
- );
474
-
475
- if (!data.config.mapboxToken) {
476
- return <SvgMapFallback data={data} />;
477
- }
478
- if (error) {
479
- return (
480
- <div className={statusCopyClass}>
481
- <WifiOff className="mr-2 inline size-4" strokeWidth={1.8} />
482
- {error}
483
- </div>
484
- );
485
- }
486
- if (!module) {
487
- return <div className={statusCopyClass}>Loading map...</div>;
488
- }
489
-
490
- const { default: Map, Marker, NavigationControl, Popup } = module;
491
- const style =
492
- document.documentElement.dataset.theme === "dark"
493
- ? "mapbox://styles/mapbox/dark-v11"
494
- : "mapbox://styles/mapbox/light-v11";
495
-
496
- return (
497
- <div className="min-h-[520px] flex-1 overflow-hidden bg-[var(--bg-active)] min-[1180px]:min-h-0">
498
- <Map
499
- ref={mapRef}
500
- initialViewState={{ longitude: 9, latitude: 24, zoom: 1.2 }}
501
- mapStyle={style}
502
- mapboxAccessToken={data.config.mapboxToken}
503
- onClick={() => setSelected(null)}
504
- onLoad={updateViewport}
505
- onMoveEnd={updateViewport}
506
- projection="mercator"
507
- style={{ width: "100%", height: "100%" }}
508
- >
509
- <NavigationControl position="top-right" showCompass={false} />
510
- {visibleClusters.map((item) => {
511
- const [longitude, latitude] = item.geometry.coordinates;
512
- if (isCluster(item)) {
513
- const fallbackCoordinates = item.geometry.coordinates as [
514
- number,
515
- number,
516
- ];
517
- const leaves = clusterIndex
518
- .getLeaves(item.properties.cluster_id, CLUSTER_LEAF_SAMPLE_SIZE)
519
- .map((leaf) => data.features[leaf.properties.featureIndex])
520
- .filter((feature): feature is MapFeature => Boolean(feature))
521
- .sort(compareClusterFeatures);
522
- const [displayLongitude, displayLatitude] = getClusterDisplayAnchor(
523
- leaves,
524
- fallbackCoordinates,
525
- );
526
- return (
527
- <Marker
528
- key={`cluster-${String(item.properties.cluster_id)}`}
529
- longitude={displayLongitude}
530
- latitude={displayLatitude}
531
- anchor="center"
532
- onClick={(event: { originalEvent?: Event }) => {
533
- event.originalEvent?.stopPropagation();
534
- const expansionZoom = Math.min(
535
- clusterIndex.getClusterExpansionZoom(
536
- item.properties.cluster_id,
537
- ),
538
- 12,
539
- );
540
- mapRef.current?.flyTo?.({
541
- center: [displayLongitude, displayLatitude],
542
- zoom: Math.max(viewport.zoom + 0.85, expansionZoom - 0.6),
543
- duration: 520,
544
- });
545
- setSelected({
546
- kind: "cluster",
547
- coordinates: [displayLongitude, displayLatitude],
548
- count: item.properties.point_count,
549
- features: leaves,
550
- stats: item.properties,
551
- });
552
- }}
553
- >
554
- <ClusterMarker
555
- count={item.properties.point_count}
556
- features={leaves}
557
- stats={item.properties}
558
- />
559
- </Marker>
560
- );
561
- }
562
- const feature = data.features[item.properties.featureIndex];
563
- if (!feature) return null;
564
- return (
565
- <Marker
566
- key={feature.properties.profileId}
567
- longitude={longitude}
568
- latitude={latitude}
569
- anchor="center"
570
- onClick={(event: { originalEvent?: Event }) => {
571
- event.originalEvent?.stopPropagation();
572
- setSelected({ kind: "profile", feature });
573
- }}
574
- >
575
- <ProfileMarker feature={feature} />
576
- </Marker>
577
- );
578
- })}
579
- {selected ? (
580
- <Popup
581
- longitude={
582
- selected.kind === "profile"
583
- ? selected.feature.geometry.coordinates[0]
584
- : selected.coordinates[0]
585
- }
586
- latitude={
587
- selected.kind === "profile"
588
- ? selected.feature.geometry.coordinates[1]
589
- : selected.coordinates[1]
590
- }
591
- closeButton={false}
592
- offset={12}
593
- onClose={() => setSelected(null)}
594
- >
595
- {selected.kind === "profile" ? (
596
- <ProfilePopup feature={selected.feature} />
597
- ) : (
598
- <ClusterPopup
599
- count={selected.count}
600
- features={selected.features}
601
- stats={selected.stats}
602
- />
603
- )}
604
- </Popup>
605
- ) : null}
606
- </Map>
607
- </div>
608
- );
609
- }
610
-
611
- function SvgMapFallback({ data }: { data: NetworkMapResponse }) {
612
- const points = data.features.slice(0, 1500).map((feature) => {
613
- const [lng, lat] = feature.geometry.coordinates;
614
- return {
615
- feature,
616
- x: ((lng + 180) / 360) * 1000,
617
- y: ((90 - lat) / 180) * 500,
618
- };
619
- });
620
- return (
621
- <div className="border-b border-[var(--line)] bg-[var(--bg-active)] px-4 py-4">
622
- <svg
623
- viewBox="0 0 1000 500"
624
- className="h-auto w-full rounded-md border border-[var(--line)] bg-[var(--bg)]"
625
- role="img"
626
- aria-label="Network map"
627
- >
628
- <defs>
629
- <pattern
630
- id="map-grid"
631
- width="100"
632
- height="100"
633
- patternUnits="userSpaceOnUse"
634
- >
635
- <path
636
- d="M 100 0 L 0 0 0 100"
637
- fill="none"
638
- stroke="currentColor"
639
- strokeOpacity="0.08"
640
- strokeWidth="1"
641
- />
642
- </pattern>
643
- </defs>
644
- <rect width="1000" height="500" fill="url(#map-grid)" />
645
- <path
646
- d="M60 205 C145 155 210 145 280 178 C352 212 424 186 495 194 C594 206 665 157 746 178 C836 201 907 227 952 282"
647
- fill="none"
648
- stroke="currentColor"
649
- strokeOpacity="0.18"
650
- strokeWidth="36"
651
- strokeLinecap="round"
652
- />
653
- {points.map(({ feature, x, y }) => (
654
- <circle
655
- key={feature.properties.profileId}
656
- cx={x}
657
- cy={y}
658
- r={feature.properties.relationship === "mutual" ? 4.5 : 3.5}
659
- fill={
660
- feature.properties.relationship === "mutual"
661
- ? "#22c55e"
662
- : feature.properties.relationship === "following"
663
- ? "#f59e0b"
664
- : "#1d9bf0"
665
- }
666
- opacity="0.86"
667
- />
668
- ))}
669
- </svg>
670
- </div>
671
- );
672
- }
673
-
674
- function ProfilePopup({ feature }: { feature: MapFeature }) {
675
- return (
676
- <div className="flex max-w-[280px] gap-3 text-[13px] text-[#0f1419]">
677
- <Avatar feature={feature} size={48} className="ring-[#d9e2ea]" />
678
- <div className="min-w-0">
679
- <div className="truncate font-bold">{feature.properties.name}</div>
680
- <div className="truncate text-[#536471]">
681
- @{feature.properties.handle}
682
- </div>
683
- <div className="mt-1 line-clamp-2">{feature.properties.location}</div>
684
- {feature.properties.resolvedLocation ? (
685
- <div className="truncate text-[#536471]">
686
- {feature.properties.resolvedLocation}
687
- </div>
688
- ) : null}
689
- </div>
690
- </div>
691
- );
692
- }
693
-
694
- function ClusterPopup({
695
- count,
696
- features,
697
- stats,
698
- }: {
699
- count: number;
700
- features: MapFeature[];
701
- stats: ClusterAggregateProperties;
702
- }) {
703
- return (
704
- <div className="w-[290px] text-[13px] text-[#0f1419]">
705
- <div className="flex items-center justify-between gap-3">
706
- <div>
707
- <div className="font-bold">{formatNumber(count)} profiles here</div>
708
- <div className="text-[#536471]">
709
- {formatNumber(stats.mutual)} mutual ·{" "}
710
- {formatNumber(stats.following)} following ·{" "}
711
- {formatNumber(stats.followers)} followers
712
- </div>
713
- </div>
714
- <div
715
- className="size-11 shrink-0 rounded-full ring-2 ring-[#d9e2ea]"
716
- style={{ background: clusterGradient(stats) }}
717
- />
718
- </div>
719
- <div className="mt-3 flex -space-x-2">
720
- {features.slice(0, 6).map((feature) => (
721
- <Avatar
722
- key={feature.properties.profileId}
723
- feature={feature}
724
- size={36}
725
- className="ring-[#ffffff]"
726
- />
727
- ))}
728
- </div>
729
- <div className="mt-3 divide-y divide-[#eff3f4]">
730
- {features.slice(0, 5).map((feature) => (
731
- <div
732
- key={feature.properties.profileId}
733
- className="flex min-w-0 items-center gap-2 py-2"
734
- >
735
- <Avatar feature={feature} size={28} className="ring-[#ffffff]" />
736
- <div className="min-w-0 flex-1">
737
- <div className="truncate font-semibold">
738
- {feature.properties.name}
739
- </div>
740
- <div className="truncate text-[#536471]">
741
- @{feature.properties.handle}
742
- </div>
743
- </div>
744
- <div
745
- className="size-2 rounded-full"
746
- style={{
747
- backgroundColor: relationshipColor(
748
- feature.properties.relationship,
749
- ),
750
- }}
751
- />
752
- </div>
753
- ))}
754
- </div>
755
- </div>
756
- );
757
- }
758
-
759
- function ProfileRow({ feature }: { feature: MapFeature }) {
760
- return (
761
- <a
762
- className="flex min-w-0 gap-3 border-b border-[var(--line)] px-4 py-3 transition-colors hover:bg-[var(--bg-hover)]"
763
- href={`/profiles/${encodeURIComponent(feature.properties.handle)}`}
764
- >
765
- <Avatar feature={feature} size={40} className="ring-[var(--bg)]" />
766
- <div className="min-w-0 flex-1">
767
- <div className="flex min-w-0 items-center gap-1.5">
768
- <span className="truncate font-bold text-[var(--ink)]">
769
- {feature.properties.name}
770
- </span>
771
- <span className="truncate text-[var(--ink-soft)]">
772
- @{feature.properties.handle}
773
- </span>
774
- </div>
775
- <div className="truncate text-[13px] text-[var(--ink-soft)]">
776
- {feature.properties.location}
777
- {feature.properties.resolvedLocation
778
- ? ` -> ${feature.properties.resolvedLocation}`
779
- : ""}
780
- </div>
781
- <div className="mt-1 flex flex-wrap gap-2 text-[12px] text-[var(--ink-soft)]">
782
- <span>{formatRelationship(feature.properties.relationship)}</span>
783
- <span>
784
- {formatNumber(feature.properties.followersCount)} followers
785
- </span>
786
- </div>
787
- </div>
788
- </a>
789
- );
790
- }
791
-
792
- function VisibleProfilesPanel({
793
- features,
794
- search,
795
- onSearchChange,
796
- totalVisible,
797
- zoom,
798
- }: {
799
- features: MapFeature[];
800
- search: string;
801
- onSearchChange: (value: string) => void;
802
- totalVisible: number;
803
- zoom: number;
804
- }) {
805
- return (
806
- <aside className="flex min-h-[420px] min-w-0 flex-col border-t border-[var(--line)] bg-[var(--bg)] min-[1180px]:h-full min-[1180px]:border-t-0 min-[1180px]:border-l">
807
- <header className="sticky top-0 z-10 border-b border-[var(--line)] bg-[color:color-mix(in_srgb,var(--bg)_88%,transparent)] px-4 py-3 backdrop-blur">
808
- <div className="flex items-start justify-between gap-3">
809
- <div className="min-w-0">
810
- <h2 className="truncate text-[16px] font-bold text-[var(--ink)]">
811
- Who's In View
812
- </h2>
813
- <p className="truncate text-[13px] text-[var(--ink-soft)]">
814
- {formatNumber(totalVisible)} in this map view · zoom{" "}
815
- {zoom.toFixed(1)}
816
- </p>
817
- </div>
818
- <div className="rounded-full border border-[var(--line)] px-2 py-1 text-[12px] font-semibold text-[var(--ink-soft)]">
819
- {formatNumber(features.length)}
820
- </div>
821
- </div>
822
- <label className={cx(searchFieldShellClass, "mt-3 h-10")}>
823
- <Search className={searchFieldIconClass} strokeWidth={2} />
824
- <input
825
- className={searchFieldInputClass}
826
- onChange={(event) => onSearchChange(event.target.value)}
827
- placeholder="Search visible people"
828
- value={search}
829
- />
830
- </label>
831
- </header>
832
- <div className="min-h-0 flex-1 overflow-y-auto">
833
- {features.length > 0 ? (
834
- features.map((feature) => (
835
- <ProfileRow key={feature.properties.profileId} feature={feature} />
836
- ))
837
- ) : (
838
- <div className={statusCopyClass}>
839
- {search.trim()
840
- ? "No visible profiles match that search."
841
- : "Pan or zoom the map to see profiles in this area."}
842
- </div>
843
- )}
844
- </div>
845
- </aside>
846
- );
847
- }
848
-
849
- function NetworkMapRoute() {
850
- const [type, setType] = useState<NetworkMapKind>("all");
851
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
852
- const [metaLoaded, setMetaLoaded] = useState(false);
853
- const [data, setData] = useState<NetworkMapResponse | null>(null);
854
- const [error, setError] = useState<string | null>(null);
855
- const [loading, setLoading] = useState(true);
856
- const [viewport, setViewport] = useState<MapViewport>(WORLD_VIEWPORT);
857
- const [visibleSearch, setVisibleSearch] = useState("");
858
- const selectedAccountId = useSelectedAccountId(meta?.accounts);
859
- const mapRequestIdRef = useRef(0);
860
- const mapAbortControllerRef = useRef<AbortController | null>(null);
861
-
862
- const load = useCallback(
863
- (refresh = false) => {
864
- const requestId = mapRequestIdRef.current + 1;
865
- mapRequestIdRef.current = requestId;
866
- mapAbortControllerRef.current?.abort();
867
- const controller = new AbortController();
868
- mapAbortControllerRef.current = controller;
869
- setLoading(true);
870
- setError(null);
871
- fetchMap(type, refresh, selectedAccountId, controller.signal)
872
- .then((nextData) => {
873
- if (mapRequestIdRef.current !== requestId) return;
874
- setData(nextData);
875
- })
876
- .catch((cause: unknown) => {
877
- if (
878
- controller.signal.aborted ||
879
- mapRequestIdRef.current !== requestId
880
- ) {
881
- return;
882
- }
883
- setError(cause instanceof Error ? cause.message : "Map unavailable");
884
- })
885
- .finally(() => {
886
- if (mapRequestIdRef.current === requestId) {
887
- setLoading(false);
888
- }
889
- });
890
- },
891
- [selectedAccountId, type],
892
- );
893
-
894
- useEffect(() => {
895
- const controller = new AbortController();
896
- fetchQueryEnvelope({ signal: controller.signal })
897
- .then(setMeta)
898
- .catch(() => {
899
- // The map can still load against the default account if status is down.
900
- })
901
- .finally(() => {
902
- if (!controller.signal.aborted) setMetaLoaded(true);
903
- });
904
- return () => controller.abort();
905
- }, []);
906
-
907
- useEffect(() => {
908
- if (!metaLoaded) return;
909
- load(false);
910
- }, [load, metaLoaded]);
911
-
912
- useEffect(
913
- () => () => {
914
- mapAbortControllerRef.current?.abort();
915
- },
916
- [],
917
- );
918
-
919
- const visibleFeatures = useMemo(
920
- () =>
921
- (data?.features ?? [])
922
- .slice()
923
- .filter((feature) => boundsContainFeature(viewport.bounds, feature))
924
- .sort(
925
- (a, b) =>
926
- b.properties.followersCount - a.properties.followersCount ||
927
- a.properties.handle.localeCompare(b.properties.handle),
928
- ),
929
- [data, viewport],
930
- );
931
-
932
- const filteredVisibleFeatures = useMemo(
933
- () =>
934
- visibleFeatures
935
- .filter((feature) => featureMatchesSearch(feature, visibleSearch))
936
- .slice(0, 160),
937
- [visibleFeatures, visibleSearch],
938
- );
939
-
940
- return (
941
- <section className="flex min-h-screen flex-col min-[1180px]:h-screen min-[1180px]:min-h-0 min-[1180px]:overflow-hidden">
942
- <header className={pageHeaderClass}>
943
- <div className={pageHeaderRowClass}>
944
- <div>
945
- <h1 className={pageTitleClass}>Network Map</h1>
946
- <div className={pageSubtitleClass}>
947
- {data
948
- ? `${formatNumber(data.meta.locatedProfiles)} located · ${formatNumber(data.meta.meaningfulProfiles)} with usable locations`
949
- : "Loading network geography..."}
950
- </div>
951
- </div>
952
- <div className={pageHeaderActionsClass}>
953
- <button
954
- className={secondaryButtonClass}
955
- type="button"
956
- onClick={() => load(true)}
957
- disabled={loading}
958
- >
959
- <RefreshCw className={cx("size-4", loading && "animate-spin")} />
960
- Refresh
961
- </button>
962
- </div>
963
- </div>
964
- <div className="border-t border-[var(--line)] px-4 py-3">
965
- <div className={segmentedClass}>
966
- {MAP_TYPES.map((item) => (
967
- <button
968
- key={item.value}
969
- type="button"
970
- className={cx(
971
- segmentClass,
972
- type === item.value && segmentActiveClass,
973
- )}
974
- onClick={() => setType(item.value)}
975
- >
976
- {item.label}
977
- </button>
978
- ))}
979
- </div>
980
- </div>
981
- </header>
982
-
983
- {error ? <div className={errorCopyClass}>{error}</div> : null}
984
- {data ? (
985
- <div className="grid min-h-[620px] flex-1 grid-cols-1 min-[1180px]:min-h-0 min-[1180px]:grid-cols-[minmax(0,1fr)_390px] min-[1180px]:overflow-hidden min-[1500px]:grid-cols-[minmax(0,1fr)_430px]">
986
- <div className="flex min-h-0 min-w-0 flex-col">
987
- <div className="grid sm:grid-cols-4">
988
- <StatTile
989
- icon={Users}
990
- label="Profiles"
991
- value={data.meta.totalProfiles}
992
- />
993
- <StatTile
994
- icon={MapPin}
995
- label="Locations"
996
- value={data.meta.meaningfulProfiles}
997
- />
998
- <StatTile
999
- icon={Globe2}
1000
- label="Located"
1001
- value={data.meta.locatedProfiles}
1002
- />
1003
- <StatTile
1004
- icon={RefreshCw}
1005
- label="Geocoded"
1006
- value={data.meta.geocodedThisRun}
1007
- />
1008
- </div>
1009
- {!data.meta.opencageConfigured && data.meta.missingGeocodes > 0 ? (
1010
- <div className={statusCopyClass}>
1011
- OpenCage key missing; showing cached and coordinate locations
1012
- only.
1013
- </div>
1014
- ) : null}
1015
- {!data.meta.mapboxTokenConfigured ? (
1016
- <div className={statusCopyClass}>
1017
- Mapbox token missing; showing the lightweight map.
1018
- </div>
1019
- ) : null}
1020
- <MapboxPanel data={data} onViewportChange={setViewport} />
1021
- </div>
1022
- <VisibleProfilesPanel
1023
- features={filteredVisibleFeatures}
1024
- onSearchChange={setVisibleSearch}
1025
- search={visibleSearch}
1026
- totalVisible={visibleFeatures.length}
1027
- zoom={viewport.zoom}
1028
- />
1029
- </div>
1030
- ) : loading ? (
1031
- <div className={statusCopyClass}>Loading map...</div>
1032
- ) : null}
1033
- </section>
1034
- );
1035
- }