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.
- package/CHANGELOG.md +33 -0
- package/README.md +10 -8
- package/bin/birdclaw.mjs +4 -66
- package/dist/cli/birdclaw.js +28165 -0
- package/dist/client/assets/AvatarChip-DLoqM5xD.js +1 -0
- package/dist/client/assets/ProfilePreview-BejAuYsJ.js +1 -0
- package/dist/client/assets/SavedTimelineView-BIS7HKpp.js +1 -0
- package/dist/client/assets/SmartTimestamp-BR0-9se2.js +1 -0
- package/dist/client/assets/TimelineRouteFrame-CyGPj0eM.js +1 -0
- package/dist/client/assets/blocks-7UDKQp8H.js +1 -0
- package/dist/client/assets/bookmarks-CbBkMvXk.js +1 -0
- package/dist/client/assets/chunk-C0phanGV.js +1 -0
- package/dist/client/assets/circle-check-C_Vq3ElD.js +1 -0
- package/dist/client/assets/client-stream-contracts-ys3XhWG4.js +5 -0
- package/dist/client/assets/createLucideIcon-JV8isFFR.js +1 -0
- package/dist/client/assets/data-sources-Cm2bpNvg.js +1 -0
- package/dist/client/assets/discuss-DTjaOugw.js +1 -0
- package/dist/client/assets/dms-CGd6Eogq.js +1 -0
- package/dist/client/assets/inbox-BHlGuOy2.js +1 -0
- package/dist/client/assets/index-CEIG7S6s.js +31 -0
- package/dist/client/assets/index-bpFrQ5yG.css +1 -0
- package/dist/client/assets/likes-cLrcAQem.js +1 -0
- package/dist/client/assets/links-CM9QO1LC.js +1 -0
- package/dist/client/assets/mapbox-BxTlqXt9.js +2 -0
- package/dist/client/assets/mapbox-gl-dgf_hX3a.js +3215 -0
- package/dist/client/assets/mentions-CxKCgzIf.js +1 -0
- package/dist/client/assets/network-map-B5a3whqj.js +2 -0
- package/dist/client/assets/profile-analyze-CRtNEwlb.js +1 -0
- package/dist/client/assets/profile-hydration-client-YEtJsNTv.js +1 -0
- package/dist/client/assets/profiles._handle-C82_HoKX.js +1 -0
- package/dist/client/assets/query-client-fYu2rzRC.js +17 -0
- package/dist/client/assets/rate-limits-ScaEwdVK.js +1 -0
- package/dist/client/assets/react-dom-DHl6Ug7F.js +1 -0
- package/dist/client/assets/react-nBylq1U5.js +1 -0
- package/dist/client/assets/repeat-2-xCwHM9hD.js +1 -0
- package/dist/client/assets/routes-q7ZaW4En.js +1 -0
- package/dist/client/assets/schemas-I9r6F6ya.js +64 -0
- package/dist/client/assets/search-Dq5kYsxA.js +1 -0
- package/dist/client/assets/sparkles-CQVlvHsZ.js +1 -0
- package/dist/client/assets/styles-B8s3tt3r.css +2 -0
- package/dist/client/assets/today-CeG1O7Py.js +1 -0
- package/dist/client/assets/triangle-alert-CBL7s2pf.js +1 -0
- package/dist/client/assets/useTimelineRouteData-1HsqgvUi.js +1 -0
- package/dist/client/assets/users-CHbKlqUr.js +1 -0
- package/dist/server/assets/AvatarChip-zUDAEmjL.js +105 -0
- package/dist/server/assets/FeedState-ntW0Q2GB.js +214 -0
- package/dist/server/assets/ProfileAnalysisStream-BFvhVdhn.js +198 -0
- package/dist/server/assets/ProfilePreview-C6gAor_j.js +752 -0
- package/dist/server/assets/SavedTimelineView-BG1jnBp1.js +64 -0
- package/dist/server/assets/SmartTimestamp-DfwE1NGf.js +45 -0
- package/dist/server/assets/SyncNowButton-BOn_IBXS.js +76 -0
- package/dist/server/assets/TimelineRouteFrame-CU7ilM7U.js +85 -0
- package/dist/server/assets/TweetRichText-DhCVv1-6.js +74 -0
- package/dist/server/assets/_tanstack-start-manifest_v-C-ZRnG_n.js +148 -0
- package/dist/server/assets/account-selection-CTdFGWRk.js +44 -0
- package/dist/server/assets/blocks-C1mgGTB3.js +299 -0
- package/dist/server/assets/blocks-DSgQghqP.js +17 -0
- package/dist/server/assets/bookmarks-BJN8tykY.js +14 -0
- package/dist/server/assets/client-stream-contracts-C2xbWVjm.js +769 -0
- package/dist/server/assets/data-sources-BnA1Q94p.js +180 -0
- package/dist/server/assets/discuss-CJ1dau1j.js +15 -0
- package/dist/server/assets/discuss-QTo08NCN.js +338 -0
- package/dist/server/assets/dms-BuLj4bp8.js +368 -0
- package/dist/server/assets/dms-DAyQrVM2.js +240 -0
- package/dist/server/assets/empty-plugin-adapters-DzrEkdBF.js +5 -0
- package/dist/server/assets/inbox-CkGOaW1q.js +214 -0
- package/dist/server/assets/inbox-DpWd1EzC.js +146 -0
- package/dist/server/assets/likes-DBOAtsJU.js +14 -0
- package/dist/server/assets/links-DAhSjynu.js +240 -0
- package/dist/server/assets/links-DmH5lxHO.js +468 -0
- package/dist/server/assets/mentions-BH2MzKtB.js +26 -0
- package/dist/server/assets/network-map-D3CzRcC4.js +224 -0
- package/dist/server/assets/network-map-D5DoLOwR.js +582 -0
- package/dist/server/assets/profile-analyze-BP65PED0.js +90 -0
- package/dist/server/assets/profile-analyze-C1NoM0tV.js +9 -0
- package/dist/server/assets/profile-hydration-client-GJ4kwJS8.js +133 -0
- package/dist/server/assets/profiles._handle-DjwlN_ac.js +184 -0
- package/dist/server/assets/profiles._handle-DsLNj2Ca.js +12 -0
- package/dist/server/assets/query-client-BdauHBgd.js +923 -0
- package/dist/server/assets/rate-limits-VAr2qzYD.js +262 -0
- package/dist/server/assets/route-search-BUlQ5lGM.js +117 -0
- package/dist/server/assets/router-DqkwSPiM.js +17026 -0
- package/dist/server/assets/routes-A9c9ZhwN.js +26 -0
- package/dist/server/assets/start-FaDOTmto.js +4 -0
- package/dist/server/assets/today-B5hVM57Z.js +17 -0
- package/dist/server/assets/today-fvGiZ7ud.js +303 -0
- package/dist/server/assets/ui-qbmcMSSX.js +137 -0
- package/{src/components/useDebouncedValue.ts → dist/server/assets/useDebouncedValue-DOnA6Xcy.js} +4 -4
- package/dist/server/assets/useTimelineRouteData-27cxynxa.js +1101 -0
- package/dist/server/server.js +5520 -0
- package/package.json +27 -34
- package/playwright.config.ts +0 -35
- package/scripts/browser-perf.mjs +0 -427
- package/scripts/build-docs-site.mjs +0 -967
- package/scripts/docs-site-assets.mjs +0 -311
- package/scripts/run-vitest.mjs +0 -21
- package/scripts/sanitize-node-options.mjs +0 -23
- package/scripts/start-test-server.mjs +0 -42
- package/src/cli-moderation.ts +0 -210
- package/src/cli.ts +0 -2336
- package/src/components/AccountSwitcher.tsx +0 -186
- package/src/components/AppNav.tsx +0 -118
- package/src/components/AvatarChip.tsx +0 -58
- package/src/components/BrandMark.tsx +0 -67
- package/src/components/ConversationThread.tsx +0 -131
- package/src/components/DmWorkspace.tsx +0 -286
- package/src/components/EmbeddedTweetCard.tsx +0 -55
- package/src/components/FeedState.tsx +0 -147
- package/src/components/FloatingPreview.tsx +0 -382
- package/src/components/InboxCard.tsx +0 -152
- package/src/components/LinkPreviewCard.tsx +0 -292
- package/src/components/MarkdownViewer.tsx +0 -777
- package/src/components/ProfileAnalysisStream.tsx +0 -428
- package/src/components/ProfilePreview.tsx +0 -129
- package/src/components/SavedTimelineView.tsx +0 -155
- package/src/components/SmartTimestamp.tsx +0 -72
- package/src/components/SyncNowButton.tsx +0 -140
- package/src/components/ThemeSlider.tsx +0 -80
- package/src/components/TimelineCard.tsx +0 -533
- package/src/components/TimelineRouteFrame.tsx +0 -186
- package/src/components/TweetArticleCard.tsx +0 -66
- package/src/components/TweetMediaGrid.tsx +0 -185
- package/src/components/TweetRichText.tsx +0 -159
- package/src/components/account-selection.ts +0 -64
- package/src/components/useTimelineRouteData.ts +0 -323
- package/src/lib/account-sync-job.ts +0 -666
- package/src/lib/actions-transport.ts +0 -300
- package/src/lib/api-client.ts +0 -419
- package/src/lib/archive-finder.ts +0 -185
- package/src/lib/archive-import.ts +0 -2764
- package/src/lib/authored-live.ts +0 -1072
- package/src/lib/avatar-cache.ts +0 -349
- package/src/lib/backup.ts +0 -2741
- package/src/lib/bird-actions.ts +0 -294
- package/src/lib/bird-command.ts +0 -130
- package/src/lib/bird.ts +0 -1140
- package/src/lib/blocklist.ts +0 -136
- package/src/lib/blocks-write.ts +0 -167
- package/src/lib/blocks.ts +0 -389
- package/src/lib/bookmark-sync-job.ts +0 -536
- package/src/lib/client-cache.ts +0 -109
- package/src/lib/config.ts +0 -224
- package/src/lib/conversation-surface.ts +0 -205
- package/src/lib/data-sources.ts +0 -219
- package/src/lib/db.ts +0 -1135
- package/src/lib/dms-live.ts +0 -933
- package/src/lib/effect-runtime.ts +0 -45
- package/src/lib/follow-graph.ts +0 -1097
- package/src/lib/geocoding.ts +0 -296
- package/src/lib/http-effect.ts +0 -222
- package/src/lib/identity-search-index.ts +0 -369
- package/src/lib/inbox.ts +0 -241
- package/src/lib/link-index.ts +0 -641
- package/src/lib/link-insights.ts +0 -860
- package/src/lib/link-preview-metadata.ts +0 -754
- package/src/lib/location.ts +0 -137
- package/src/lib/media-fetch.ts +0 -860
- package/src/lib/media-includes.ts +0 -165
- package/src/lib/mention-threads-live.ts +0 -917
- package/src/lib/mentions-export.ts +0 -147
- package/src/lib/mentions-live.ts +0 -1238
- package/src/lib/moderation-target.ts +0 -216
- package/src/lib/moderation-write.ts +0 -131
- package/src/lib/mutes-write.ts +0 -167
- package/src/lib/mutes.ts +0 -84
- package/src/lib/ndjson-stream.ts +0 -106
- package/src/lib/network-map.ts +0 -382
- package/src/lib/openai.ts +0 -117
- package/src/lib/period-digest.ts +0 -1552
- package/src/lib/present.ts +0 -118
- package/src/lib/profile-affiliation-hydration.ts +0 -228
- package/src/lib/profile-affiliations.ts +0 -262
- package/src/lib/profile-analysis.ts +0 -1272
- package/src/lib/profile-bio-entities.ts +0 -318
- package/src/lib/profile-history.ts +0 -164
- package/src/lib/profile-hydration.ts +0 -180
- package/src/lib/profile-replies.ts +0 -77
- package/src/lib/profile-resolver.ts +0 -650
- package/src/lib/queries.ts +0 -2381
- package/src/lib/research.ts +0 -652
- package/src/lib/search-discussion.ts +0 -1016
- package/src/lib/seed.ts +0 -613
- package/src/lib/sqlite.ts +0 -147
- package/src/lib/sync-cache.ts +0 -65
- package/src/lib/theme-transition.ts +0 -113
- package/src/lib/theme.tsx +0 -165
- package/src/lib/timeline-collections-live.ts +0 -581
- package/src/lib/timeline-live.ts +0 -452
- package/src/lib/tweet-account-edges.ts +0 -48
- package/src/lib/tweet-lookup.ts +0 -46
- package/src/lib/tweet-render.ts +0 -430
- package/src/lib/tweet-search-live.ts +0 -566
- package/src/lib/types.ts +0 -723
- package/src/lib/ui.ts +0 -529
- package/src/lib/url-expansion-store.ts +0 -110
- package/src/lib/url-expansion.ts +0 -343
- package/src/lib/url-safety.ts +0 -220
- package/src/lib/web-sync.ts +0 -516
- package/src/lib/whois.ts +0 -1079
- package/src/lib/x-profile.ts +0 -365
- package/src/lib/x-web.ts +0 -199
- package/src/lib/xurl-rate-limits.ts +0 -267
- package/src/lib/xurl.ts +0 -1677
- package/src/routeTree.gen.ts +0 -723
- package/src/router.tsx +0 -20
- package/src/routes/__root.tsx +0 -79
- package/src/routes/api/action.tsx +0 -147
- package/src/routes/api/avatar.tsx +0 -50
- package/src/routes/api/blocks.tsx +0 -35
- package/src/routes/api/conversation.tsx +0 -45
- package/src/routes/api/data-sources.tsx +0 -24
- package/src/routes/api/inbox.tsx +0 -43
- package/src/routes/api/link-insights.tsx +0 -85
- package/src/routes/api/link-preview.tsx +0 -47
- package/src/routes/api/network-map.tsx +0 -55
- package/src/routes/api/period-digest.tsx +0 -101
- package/src/routes/api/profile-analysis.tsx +0 -97
- package/src/routes/api/profile-hydrate.tsx +0 -54
- package/src/routes/api/query.tsx +0 -115
- package/src/routes/api/search-discussion.tsx +0 -113
- package/src/routes/api/status.tsx +0 -28
- package/src/routes/api/sync.tsx +0 -105
- package/src/routes/api/xurl-rate-limits.tsx +0 -24
- package/src/routes/blocks.tsx +0 -377
- package/src/routes/bookmarks.tsx +0 -18
- package/src/routes/data-sources.tsx +0 -257
- package/src/routes/discuss.tsx +0 -419
- package/src/routes/dms.tsx +0 -432
- package/src/routes/inbox.tsx +0 -226
- package/src/routes/index.tsx +0 -32
- package/src/routes/likes.tsx +0 -18
- package/src/routes/links.tsx +0 -994
- package/src/routes/mentions.tsx +0 -32
- package/src/routes/network-map.tsx +0 -1035
- package/src/routes/profile-analyze.tsx +0 -112
- package/src/routes/profiles.$handle.tsx +0 -228
- package/src/routes/rate-limits.tsx +0 -309
- package/src/routes/today.tsx +0 -511
- package/src/styles.css +0 -257
- package/tsconfig.json +0 -29
- package/vite.config.ts +0 -29
- /package/{public → dist/client}/birdclaw-mark.png +0 -0
- /package/{public → dist/client}/favicon.ico +0 -0
- /package/{public → dist/client}/logo192.png +0 -0
- /package/{public → dist/client}/logo512.png +0 -0
- /package/{public → dist/client}/manifest.json +0 -0
- /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
|
-
}
|