diff --git a/NIP.md b/NIP.md index 7bc9d995..c27a180b 100644 --- a/NIP.md +++ b/NIP.md @@ -16,6 +16,7 @@ These event kinds were created by community contributors and are supported by Di | Kind | Name | Description | Spec | |-------|------------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------| +| 2473 | Bird Detection | Bird-by-ear observation log (species heard in the wild) | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) | | 3367 | Color Moment | Color palette post expressing a mood | [NIP](https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md) | | 4223 | Weather Reading | Sensor readings from a weather station | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) | | 7516 | Found Log | Log entry recording a user finding a geocache | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) | @@ -29,6 +30,7 @@ These event kinds were created by community contributors and are supported by Di | 37516 | Geocache | Geocache listing for real-world treasure hunting | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) | | 36787 | Music Track | Addressable event for a music audio file with metadata | See [Music Tracks & Playlists](#music-tracks--playlists) below | | 34139 | Music Playlist | Ordered list of music track references (also used for albums) | See [Music Tracks & Playlists](#music-tracks--playlists) below | +| 30621 | Custom Constellation | User-drawn star figure with Hipparcos-numbered edges | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) | --- @@ -329,6 +331,17 @@ The following specifications are maintained by their respective authors. Ditto i Color palette posts capturing 3-6 colors from a beautiful moment, optionally accompanied by an emoji and layout preference. Supports horizontal, vertical, grid, star, checkerboard, and diagonal stripe layouts. A form of pre-verbal visual communication through color and emotion. +### Birdstar (Kinds 2473, 30621) + +**Author:** Alex Gleason +**Spec:** https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md +**App:** https://birdstar.app + +Birdstar merges Birdsong Spotter (a bird-by-ear checklist) and Starpoint (an interactive sky map with community constellations) into a single client. + +- **Kind 2473 — Bird Detection.** A regular event representing a single identified bird observation. The species is identified by a NIP-73 `i`/`k` pair pointing at the species' Wikidata entity URI (e.g. `https://www.wikidata.org/entity/Q26825` for the American Robin). The `content` field holds an optional freeform human note about the detection. Required tags: NIP-31 `alt`, NIP-73 `i` (Wikidata URL) + `k` (`web`). Ditto renders detections as a species card with the Wikipedia thumbnail, common/scientific name, and article summary. +- **Kind 30621 — Custom Constellation.** An addressable event (`d` tag) representing a single user-drawn star figure. Each `edge` tag (`["edge", from, to]`) references two Hipparcos catalog numbers as decimal strings — e.g. `["edge", "32349", "37279"]` for Sirius → Procyon. Required tags: `d`, `title`, `alt`, and at least one valid `edge`. The `content` field is a freeform description. Ditto renders constellations as a stylized SVG star-map (gnomonically projected onto a tangent plane at the figure's centroid, with stars sized by magnitude) using a bundled Hipparcos catalog that is code-split so the data only loads when a constellation is actually viewed. + ### Geocaching (Kinds 37516, 7516) **Author:** Chad Curtis diff --git a/package-lock.json b/package-lock.json index debcc72a..c58ca950 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,6 +101,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "d3-celestial": "^0.7.35", "date-fns": "^3.6.0", "dompurify": "^3.3.3", "embla-carousel-react": "^8.3.0", @@ -8106,6 +8107,12 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz", + "integrity": "sha512-yFk/2idb8OHPKkbAL8QaOaqENNoMhIaSHZerk3oQsECwkObkCpJyjYwCe+OHiq6UEdhe1m8ZGARRRO3ljFjlKg==", + "license": "BSD-3-Clause" + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -8118,6 +8125,15 @@ "node": ">=12" } }, + "node_modules/d3-celestial": { + "version": "0.7.35", + "resolved": "https://registry.npmjs.org/d3-celestial/-/d3-celestial-0.7.35.tgz", + "integrity": "sha512-cURxIl0E+FGWnYj6gTDt80SjuiM9lklcGykj/skVy7glDg5nj/QxTUoPPArU+bpEQ+1fLy5hi920OvJ/TgliRw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3": "^3.5.17" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", diff --git a/package.json b/package.json index 3fb2d44b..0ee8eb1e 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "d3-celestial": "^0.7.35", "date-fns": "^3.6.0", "dompurify": "^3.3.3", "embla-carousel-react": "^8.3.0", diff --git a/src/App.tsx b/src/App.tsx index 0c6e4954..f5c80a90 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -112,6 +112,9 @@ const hardcodedConfig: AppConfig = { feedIncludeBadgeAwards: true, feedIncludeVanish: true, feedIncludeBlobbi: true, + showBirdstar: true, + feedIncludeBirdDetections: true, + feedIncludeConstellations: true, followsFeedShowReplies: true, }, sidebarOrder: [ diff --git a/src/components/BirdDetectionContent.tsx b/src/components/BirdDetectionContent.tsx new file mode 100644 index 00000000..9a5235d2 --- /dev/null +++ b/src/components/BirdDetectionContent.tsx @@ -0,0 +1,175 @@ +import { useMemo } from 'react'; +import { Bird, ExternalLink } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { Skeleton } from '@/components/ui/skeleton'; +import { useWikidataEntity } from '@/hooks/useWikidataEntity'; +import { useWikipediaSummary } from '@/hooks/useWikipediaSummary'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { cn } from '@/lib/utils'; + +/** + * Birdstar kind 2473 — Bird Detection. + * + * The species is identified by a NIP-73 `i`/`k` pair pointing at a Wikidata + * entity URI (e.g. `https://www.wikidata.org/entity/Q26825`). We resolve it + * through Wikidata → English Wikipedia to get a display name, a short + * summary, and a thumbnail image. + * + * Structured data lives in tags; `content` is an optional freeform note. + */ + +const WIKIDATA_URL_RE = /^https:\/\/www\.wikidata\.org\/entity\/(Q\d+)$/; + +interface BirdDetectionContentProps { + event: NostrEvent; + className?: string; +} + +function extractWikidataId(tags: string[][]): string | null { + // A valid detection pairs an `i` tag with `k: web`. There may be multiple + // i/k pairs in principle; we take the first `i` whose URL matches. + for (const tag of tags) { + if (tag[0] !== 'i') continue; + const value = tag[1]; + if (typeof value !== 'string') continue; + const m = value.match(WIKIDATA_URL_RE); + if (m) return m[1]; + } + return null; +} + +/** Pull a species label from the `alt` tag: "Bird detection: American Robin (Turdus migratorius)". */ +function extractAltSpecies(tags: string[][]): { common?: string; scientific?: string } | null { + const alt = tags.find(([n]) => n === 'alt')?.[1]; + if (!alt) return null; + // Match "Bird detection: ()" — keep the parser loose + // so subtly different NIP-31 prefixes still yield a usable label. + const m = alt.match(/^[^:]*:\s*([^()]+?)\s*(?:\(([^)]+)\))?\s*$/); + if (!m) return { common: alt }; + return { common: m[1]?.trim(), scientific: m[2]?.trim() }; +} + +export function BirdDetectionContent({ event, className }: BirdDetectionContentProps) { + const wikidataId = useMemo(() => extractWikidataId(event.tags), [event.tags]); + const altSpecies = useMemo(() => extractAltSpecies(event.tags), [event.tags]); + const note = event.content.trim(); + + // Resolve Wikidata → English Wikipedia title, then fetch the Wikipedia + // summary (extract + thumbnail) for the title. + const { data: entity, isLoading: entityLoading } = useWikidataEntity(wikidataId); + const wikipediaTitle = entity?.wikipediaTitle ?? null; + const { data: summary, isLoading: summaryLoading } = useWikipediaSummary(wikipediaTitle); + + const isLoading = entityLoading || summaryLoading; + + // Prefer the Wikipedia page title for the display name when available, + // but fall back to the species parsed from the `alt` tag so the card is + // still meaningful while the Wikipedia fetch is in flight (or has failed). + const commonName = summary?.title ?? altSpecies?.common ?? 'Unknown species'; + const scientificName = altSpecies?.scientific; + const extract = summary?.extract; + const thumbnail = sanitizeUrl(summary?.thumbnail?.source); + const articleUrl = sanitizeUrl(summary?.articleUrl); + + // When the user's own freeform note exists we show it above the + // Wikipedia-derived summary. `content` can be empty per the NIP. + const timeStr = new Date(event.created_at * 1000).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + + if (!wikidataId) { + // Shouldn't happen for a valid kind 2473 (the NIP requires the i tag), + // but render something useful rather than silently dropping the event. + return ( +
+ Bird detection with no species reference. +
+ ); + } + + return ( +
+
+ {/* Thumbnail panel */} +
+ {isLoading ? ( + + ) : thumbnail ? ( + {commonName} + ) : ( +
+ +
+ )} +
+
+ {timeStr} +
+
+ + {/* Text panel */} +
+
+
+ +

+ {commonName} +

+
+ {scientificName && ( +

+ {scientificName} +

+ )} +
+ + {isLoading ? ( +
+ + + +
+ ) : extract ? ( +

+ {extract} +

+ ) : ( +

+ Heard at {new Date(event.created_at * 1000).toLocaleString()}. +

+ )} + + {articleUrl && ( + e.stopPropagation()} + className="inline-flex w-fit items-center gap-1 pt-0.5 text-[11px] font-medium text-muted-foreground hover:text-foreground" + > + + Wikipedia + + )} +
+
+ + {note && ( +

+ {note} +

+ )} +
+ ); +} diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index ace61e81..a8ebb646 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -3,10 +3,10 @@ import { type ReactNode, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { - Award, BarChart3, BookOpen, Camera, Clapperboard, Egg, FileText, Film, + Award, BarChart3, BookOpen, Bird, Camera, Clapperboard, Egg, FileText, Film, GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music, Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles, - UserCheck, Users, Vote, Zap, + Stars, UserCheck, Users, Vote, Zap, } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -116,6 +116,7 @@ const KIND_LABELS: Record = { 8211: 'a letter', 1617: 'a patch', 1618: 'a pull request', + 2473: 'a bird detection', 3367: 'a color moment', 7516: 'a found log', 15128: 'an nsite', @@ -146,6 +147,7 @@ const KIND_LABELS: Record = { 37381: 'a Magic deck', 37516: 'a treasure', 30000: 'a follow set', + 30621: 'a constellation', 39089: 'a follow pack', 9735: 'a zap', 31124: 'a Blobbi', @@ -198,6 +200,8 @@ const KIND_ICONS: Partial> = { 39089: 'follow pack', 37381: 'deck', 37516: 'treasure', + 30621: 'constellation', 34550: 'community', 30054: 'episode', 30055: 'trailer', diff --git a/src/components/ConstellationContent.tsx b/src/components/ConstellationContent.tsx new file mode 100644 index 00000000..e10b3565 --- /dev/null +++ b/src/components/ConstellationContent.tsx @@ -0,0 +1,91 @@ +import { lazy, Suspense, useMemo } from 'react'; +import { Sparkles } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; + +/** + * Birdstar kind 30621 — Custom Constellation. + * + * An addressable event carrying a user-drawn star-figure: a title, a + * freeform description in `content`, and one or more `edge` tags referencing + * pairs of Hipparcos catalog numbers (e.g. `["edge", "32349", "37279"]`). + * + * Rendering the figure requires the full Hipparcos star catalog (~1.3 MB), + * so the preview component is code-split via `lazy()` — the catalog data + * only loads when a user actually scrolls a constellation event into view. + */ + +const ConstellationStarMap = lazy(() => + import('./ConstellationStarMap').then((m) => ({ default: m.ConstellationStarMap })), +); + +interface ConstellationContentProps { + event: NostrEvent; + className?: string; +} + +interface ParsedConstellation { + title: string; + description: string; + edges: Array; +} + +function parseConstellation(event: NostrEvent): ParsedConstellation { + const title = event.tags.find(([n]) => n === 'title')?.[1] + ?? event.tags.find(([n]) => n === 'd')?.[1] + ?? 'Untitled constellation'; + + const edges: Array = []; + for (const tag of event.tags) { + if (tag[0] !== 'edge' || tag.length < 3) continue; + const from = Number(tag[1]); + const to = Number(tag[2]); + // Reject non-positive-integer HIP numbers per the NIP's validation rules. + if (!Number.isInteger(from) || from <= 0) continue; + if (!Number.isInteger(to) || to <= 0) continue; + edges.push([from, to] as const); + } + + return { + title, + description: event.content.trim(), + edges, + }; +} + +export function ConstellationContent({ event, className }: ConstellationContentProps) { + const { title, description, edges } = useMemo(() => parseConstellation(event), [event]); + + return ( +
+
+ {/* Star map */} +
+ }> + + +
+ + {/* Title + description */} +
+
+ +

+ {title} +

+ + {edges.length} {edges.length === 1 ? 'edge' : 'edges'} + +
+ {description && ( +

+ {description} +

+ )} +
+
+
+ ); +} diff --git a/src/components/ConstellationStarMap.tsx b/src/components/ConstellationStarMap.tsx new file mode 100644 index 00000000..ef473a8c --- /dev/null +++ b/src/components/ConstellationStarMap.tsx @@ -0,0 +1,322 @@ +import { useId, useMemo } from 'react'; + +import { cn } from '@/lib/utils'; +import { starByHip } from '@/lib/starCatalog'; + +/** + * Renders a custom constellation as an SVG star-map. + * + * This component is code-split via `lazy()` from `ConstellationContent`: + * the Hipparcos star catalog it imports is ~1.3 MB of JSON and must never + * ship in the main bundle. + * + * The figure is gnomonically projected onto a tangent plane centered on the + * centroid of its stars (on the unit sphere) and then normalized to fit the + * SVG viewBox with equal aspect, so shapes are never distorted. Stars are + * sized by apparent magnitude, with the brightest few getting a soft glow + * to evoke a real sky. + * + * Adapted from the `ConstellationPreview` component in the Birdstar + * reference client. + */ + +export interface ConstellationStarMapProps { + edges: ReadonlyArray; + title?: string; + className?: string; +} + +const DEG = Math.PI / 180; +const HOUR = (15 * Math.PI) / 180; // 1h = 15° + +interface ResolvedStar { + hip: number; + ra: number; // hours + dec: number; // degrees + mag: number; +} + +interface ProjectedPoint { + hip: number; + x: number; + y: number; + r: number; +} + +interface ProjectedEdge { + x1: number; + y1: number; + x2: number; + y2: number; +} + +interface BackgroundStar { + x: number; + y: number; + r: number; + o: number; +} + +interface ProjectionResult { + points: Map; + edges: ProjectedEdge[]; + backgroundStars: BackgroundStar[]; +} + +export function ConstellationStarMap({ edges, title, className }: ConstellationStarMapProps) { + // A stable unique id keeps multiple previews on the page from colliding on + // the shared id. + const rawId = useId(); + const uid = rawId.replace(/:/g, ''); + const glowId = `cm-glow-${uid}`; + + const projected = useMemo(() => project(edges), [edges]); + + if (!projected || projected.points.size === 0) { + return ( +
+ No recognizable stars. +
+ ); + } + + const { points, edges: projEdges, backgroundStars } = projected; + + return ( +
+ {/* Background field stars — cover the whole container regardless of + aspect ratio, so corners never look bare. */} + + + {backgroundStars.map((s, i) => ( + + ))} + + + + {/* Figure — preserves aspect so stick-figures never distort. */} + + + + + + + + + + + + {/* Edges */} + + {projEdges.map((e, i) => ( + + ))} + + + {/* Figure stars with soft glow */} + + {Array.from(points.values()).map((p) => ( + + ))} + + +
+ ); +} + +function project(edges: ReadonlyArray): ProjectionResult | null { + // Collect unique stars referenced by the figure. Unknown HIP numbers are + // silently dropped per the NIP's validation rules. + const stars = new Map(); + for (const [a, b] of edges) { + if (!stars.has(a)) { + const s = starByHip(a); + if (s) stars.set(a, { hip: s.hip, ra: s.ra, dec: s.dec, mag: s.mag }); + } + if (!stars.has(b)) { + const s = starByHip(b); + if (s) stars.set(b, { hip: s.hip, ra: s.ra, dec: s.dec, mag: s.mag }); + } + } + if (stars.size === 0) return null; + + // Mean unit-vector as the projection tangent point — handles wrap-around + // at RA=0h/24h and the poles without special-casing. + let mx = 0; + let my = 0; + let mz = 0; + for (const s of stars.values()) { + const raRad = s.ra * HOUR; + const decRad = s.dec * DEG; + const cosDec = Math.cos(decRad); + mx += cosDec * Math.cos(raRad); + my += cosDec * Math.sin(raRad); + mz += Math.sin(decRad); + } + const norm = Math.hypot(mx, my, mz) || 1; + mx /= norm; + my /= norm; + mz /= norm; + + const centerDec = Math.asin(Math.max(-1, Math.min(1, mz))); + const centerRa = Math.atan2(my, mx); + const sinC = Math.sin(centerDec); + const cosC = Math.cos(centerDec); + + // Gnomonic projection onto a tangent plane at (centerRa, centerDec). + const raw = new Map(); + for (const s of stars.values()) { + const ra = s.ra * HOUR; + const dec = s.dec * DEG; + const cosDec = Math.cos(dec); + const sinDec = Math.sin(dec); + const dRa = ra - centerRa; + const cosDRa = Math.cos(dRa); + const sinDRa = Math.sin(dRa); + const cosDistance = sinC * sinDec + cosC * cosDec * cosDRa; + if (cosDistance <= 1e-6) continue; + const x = (cosDec * sinDRa) / cosDistance; + const y = (cosC * sinDec - sinC * cosDec * cosDRa) / cosDistance; + // Flip x so RA increases to the left (conventional sky orientation). + raw.set(s.hip, { x: -x, y, mag: s.mag }); + } + if (raw.size === 0) return null; + + // Bounding box. + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const p of raw.values()) { + if (p.x < minX) minX = p.x; + if (p.x > maxX) maxX = p.x; + if (p.y < minY) minY = p.y; + if (p.y > maxY) maxY = p.y; + } + + const PADDING = 14; + const AVAILABLE = 100 - PADDING * 2; + const spanX = maxX - minX; + const spanY = maxY - minY; + const span = Math.max(spanX, spanY); + const scale = span > 1e-9 ? AVAILABLE / span : 0; + const offsetX = (AVAILABLE - spanX * scale) / 2 + PADDING; + const offsetY = (AVAILABLE - spanY * scale) / 2 + PADDING; + + const points = new Map(); + for (const [hip, p] of raw) { + const x = (p.x - minX) * scale + offsetX; + // Invert SVG y so north-ish stars sit on top. + const y = 100 - ((p.y - minY) * scale + offsetY); + points.set(hip, { hip, x, y, r: magToRadius(p.mag) }); + } + + const projEdges: ProjectedEdge[] = []; + for (const [a, b] of edges) { + const pa = points.get(a); + const pb = points.get(b); + if (!pa || !pb) continue; + projEdges.push({ x1: pa.x, y1: pa.y, x2: pb.x, y2: pb.y }); + } + + // Deterministic scatter of faint background stars seeded from the edge + // list, so the same figure always renders identically. + const backgroundStars = makeBackgroundStars(edges, points); + + return { points, edges: projEdges, backgroundStars }; +} + +function makeBackgroundStars( + edges: ReadonlyArray, + figure: Map, +): BackgroundStar[] { + let seed = 2166136261; + for (const [a, b] of edges) { + seed ^= a * 16777619; + seed = Math.imul(seed, 16777619); + seed ^= b * 2246822519; + seed = Math.imul(seed, 16777619); + } + const rand = mulberry32(seed >>> 0); + + const MIN_DIST = 5; // clearance from figure stars (viewBox units) + const out: BackgroundStar[] = []; + const figurePts = Array.from(figure.values()); + let attempts = 0; + while (out.length < 22 && attempts < 120) { + attempts++; + const x = rand() * 100; + const y = rand() * 100; + let tooClose = false; + for (const p of figurePts) { + if (Math.hypot(p.x - x, p.y - y) < MIN_DIST) { + tooClose = true; + break; + } + } + if (tooClose) continue; + out.push({ x, y, r: 0.2 + rand() * 0.5, o: 0.3 + rand() * 0.55 }); + } + return out; +} + +function mulberry32(a: number): () => number { + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Map apparent magnitude to a preview dot radius in viewBox units. + * Brighter stars (lower magnitude) get larger dots, clamped to keep mag~6 + * stars visible and mag~0 stars from dominating the thumbnail. + */ +function magToRadius(mag: number): number { + const r = 2.3 - 0.25 * mag; + if (r < 0.8) return 0.8; + if (r > 2.4) return 2.4; + return r; +} diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index edbcf82d..af1eb525 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; -import { BookOpen, Droplets, ExternalLink, FileText, Globe, MapPin, MessageCircle, Package, Play, Repeat2, Share2, User, Users, Wind } from 'lucide-react'; +import { BookOpen, Bird, Droplets, ExternalLink, FileText, Globe, MapPin, MessageCircle, Package, Play, Repeat2, Share2, Stars, User, Users, Wind } from 'lucide-react'; import { nip19 } from 'nostr-tools'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; @@ -1223,6 +1223,8 @@ const WELL_KNOWN_KIND_LABELS: Record = { 15128: 'Nsite', 35128: 'Nsite', 31124: 'Blobbi', + 2473: 'Bird Detection', + 30621: 'Constellation', }; export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey: string; identifier: string } }) { @@ -1248,6 +1250,8 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey if (addr.kind === 31990 || addr.kind === 32267 || addr.kind === 30063 || addr.kind === 3063) return Package; if (addr.kind === 15128 || addr.kind === 35128) return Globe; if (addr.kind === 3 || addr.kind === 30000) return Users; + if (addr.kind === 2473) return Bird; + if (addr.kind === 30621) return Stars; return FileText; }, [kindDef, addr.kind]); diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index e9849240..534959e7 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -1,6 +1,7 @@ import type { NostrEvent } from "@nostrify/nostrify"; import { Award, + Bird, Camera, Egg, FileCode, @@ -20,6 +21,7 @@ import { SmilePlus, PartyPopper, Sparkles, + Stars, UserCheck, Users, Volume2, @@ -54,6 +56,8 @@ import { FileMetadataContent } from "@/components/FileMetadataContent"; import { PeopleListContent } from "@/components/PeopleListContent"; import { FoundLogContent } from "@/components/FoundLogContent"; import { GeocacheContent } from "@/components/GeocacheContent"; +import { BirdDetectionContent } from "@/components/BirdDetectionContent"; +import { ConstellationContent } from "@/components/ConstellationContent"; import { GitRepoCard } from "@/components/GitRepoCard"; import { NsiteCard } from "@/components/NsiteCard"; import { ImageGallery } from "@/components/ImageGallery"; @@ -382,6 +386,8 @@ export const NoteCard = memo(function NoteCard({ const isGeocache = event.kind === 37516; const isFoundLog = event.kind === 7516; const isColor = event.kind === 3367; + const isBirdDetection = event.kind === 2473; + const isConstellation = event.kind === 30621; const isPeopleList = event.kind === 3 || event.kind === 30000 || event.kind === 39089; const isArticle = event.kind === 30023; const isMagicDeck = event.kind === 37381; @@ -430,6 +436,8 @@ export const NoteCard = memo(function NoteCard({ !isGeocache && !isFoundLog && !isColor && + !isBirdDetection && + !isConstellation && !isPeopleList && !isArticle && !isMagicDeck && @@ -576,6 +584,10 @@ export const NoteCard = memo(function NoteCard({ ) : isColor ? ( + ) : isBirdDetection ? ( + + ) : isConstellation ? ( + ) : isPeopleList ? ( ) : isArticle ? ( @@ -1834,6 +1846,16 @@ const KIND_HEADER_MAP: Record = { noun: "playlist", nounRoute: "/music", }, + 2473: { + icon: Bird, + action: "heard a", + noun: "bird", + }, + 30621: { + icon: Stars, + action: (event) => publishedAtAction(event, { created: "drew a", updated: "redrew a", fallback: "drew a" }), + noun: "constellation", + }, 3: { icon: UserCheck, action: "updated their", diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 9799a870..fca32852 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -154,6 +154,12 @@ export interface FeedSettings { feedIncludeVanish: boolean; /** Include Blobbi pet updates (kind 31124) in the follows/global feed */ feedIncludeBlobbi: boolean; + /** Show Birdstar (kind 2473 bird detections + kind 30621 custom constellations) link in sidebar */ + showBirdstar: boolean; + /** Include bird detections (kind 2473) in the follows/global feed */ + feedIncludeBirdDetections: boolean; + /** Include custom constellations (kind 30621) in the follows/global feed */ + feedIncludeConstellations: boolean; /** Include replies in the follows feed (default: true) */ followsFeedShowReplies: boolean; } diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index 883b3695..9d8f2f35 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -1,6 +1,6 @@ import type { FeedSettings } from '@/contexts/AppContext'; import type { ComponentType } from 'react'; -import { Globe, GitPullRequestArrow, MessageSquareMore, CircleAlert, UserCheck, Users } from 'lucide-react'; +import { Bird, Globe, GitPullRequestArrow, MessageSquareMore, CircleAlert, Stars, UserCheck, Users } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems'; @@ -482,6 +482,31 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ feedOnly: true, blurb: 'Virtual pet companions living on Nostr. Care for them, watch them grow, and share their journey.', }, + // Birdstar (feed-only — external app, no Ditto page) + { + kind: 2473, + id: 'bird-detections', + feedKey: 'feedIncludeBirdDetections', + label: 'Bird Detections', + description: 'Species heard in the wild (Birdsong Spotter)', + addressable: false, + section: 'whimsy', + feedOnly: true, + blurb: 'Bird-by-ear detections — someone heard a species sing or call, and logged the sighting. Identified by Wikidata entity.', + sites: [{ url: 'https://birdstar.app', name: 'Birdstar' }], + }, + { + kind: 30621, + id: 'constellations', + feedKey: 'feedIncludeConstellations', + label: 'Constellations', + description: 'User-drawn custom star figures (Starpoint)', + addressable: true, + section: 'whimsy', + feedOnly: true, + blurb: 'Custom constellations drawn star-by-star on an interactive sky map. Trace your own figures and share them on Nostr.', + sites: [{ url: 'https://birdstar.app', name: 'Birdstar' }], + }, // Development { kind: 30617, @@ -577,6 +602,8 @@ const KIND_SPECIFIC_ICONS: Partial n === 't' && v === 'hidden')) return true; // Emoji packs (kind 30030) without at least one valid emoji tag if (event.kind === 30030 && !event.tags.some(([n, sc, url]) => n === 'emoji' && sc && url)) return true; + // Bird detections (kind 2473) without a Wikidata entity reference — the NIP + // requires an `i` tag pointing at https://www.wikidata.org/entity/Q. + if (event.kind === 2473) { + const wikidataRe = /^https:\/\/www\.wikidata\.org\/entity\/Q\d+$/; + if (!event.tags.some(([n, v]) => n === 'i' && typeof v === 'string' && wikidataRe.test(v))) return true; + } + // Custom constellations (kind 30621) without any valid edge tags + if (event.kind === 30621) { + const hasEdge = event.tags.some(([n, from, to]) => n === 'edge' && /^\d+$/.test(from ?? '') && /^\d+$/.test(to ?? '')); + if (!hasEdge) return true; + } return false; } diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 0adc4749..a7340dc2 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -183,6 +183,9 @@ export const FeedSettingsSchema = z.looseObject({ feedIncludeBlobbi: z.boolean().optional(), showBadgeAwards: z.boolean().optional(), feedIncludeBadgeAwards: z.boolean().optional(), + showBirdstar: z.boolean().optional(), + feedIncludeBirdDetections: z.boolean().optional(), + feedIncludeConstellations: z.boolean().optional(), }); /** Schema for a NIP-01 filter object (lenient — allows variable placeholder strings). */ diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index 29d83766..133a8a43 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -3,6 +3,7 @@ import { Award, BarChart3, Bell, + Bird, Blocks, BookMarked, Bookmark, @@ -32,6 +33,7 @@ import { Smile, SmilePlus, Sparkles, + Stars, TrendingUp, User, } from "lucide-react"; @@ -195,6 +197,8 @@ export const CONTENT_KIND_ICONS: Record = { voice: Mic, "custom-emojis": Smile, statuses: SmilePlus, + "bird-detections": Bird, + constellations: Stars, ...Object.fromEntries( SIDEBAR_ITEMS.filter((s) => s.icon).map((s) => [s.id, s.icon]), ), diff --git a/src/lib/starCatalog.ts b/src/lib/starCatalog.ts new file mode 100644 index 00000000..071302e5 --- /dev/null +++ b/src/lib/starCatalog.ts @@ -0,0 +1,101 @@ +/** + * Hipparcos star catalog for Birdstar kind 30621 (Custom Constellation) + * rendering. + * + * Data is bundled from the `d3-celestial` npm package (BSD-3-Clause, Olaf + * Frohn), which in turn pulls from: + * + * - ESA's Hipparcos mission catalog (positions, magnitudes) + * - IAU Catalog of Star Names (proper names) + * + * Attribution: https://github.com/ofrohn/d3-celestial + * + * The raw GeoJSON is ~1.3 MB combined, so this module is only ever imported + * dynamically (see `ConstellationContent`) — it must never appear in the + * main bundle. + */ + +import starsGeoJson from 'd3-celestial/data/stars.6.json'; +import starnamesJson from 'd3-celestial/data/starnames.json'; + +// --------------------------------------------------------------------------- +// Source-data types +// --------------------------------------------------------------------------- + +interface StarFeature { + type: 'Feature'; + id: number; // HIP catalog number + properties: { mag: number }; + geometry: { type: 'Point'; coordinates: [number, number] }; // [RA°, Dec°] +} + +interface StarsCollection { + type: 'FeatureCollection'; + features: StarFeature[]; +} + +interface StarNameEntry { + name?: string; + bayer?: string; + c?: string; // 3-letter IAU constellation code +} + +type StarNames = Record; + +const RAW_STARS = starsGeoJson as unknown as StarsCollection; +const RAW_NAMES = starnamesJson as unknown as StarNames; + +// --------------------------------------------------------------------------- +// Public Star type +// --------------------------------------------------------------------------- + +export interface Star { + /** Hipparcos catalog number — stable, used as the wire identifier. */ + hip: number; + /** IAU / traditional proper name, if any. */ + name?: string; + /** Bayer designation (Greek letter, pre-encoded as Unicode). */ + bayer?: string; + /** 3-letter IAU constellation code (e.g. "CMa"). */ + constellation?: string; + /** Right ascension in hours, 0..24. */ + ra: number; + /** Declination in degrees, -90..90. */ + dec: number; + /** Apparent visual magnitude. */ + mag: number; +} + +// --------------------------------------------------------------------------- +// Build the catalog once at module-load time. +// --------------------------------------------------------------------------- + +const STARS_BY_HIP = new Map(); + +for (const f of RAW_STARS.features) { + const hip = f.id; + if (typeof hip !== 'number' || !Number.isFinite(hip) || hip <= 0) continue; + const mag = Number(f.properties.mag); + if (!Number.isFinite(mag)) continue; + + const [lon, lat] = f.geometry.coordinates; + // d3-celestial encodes RA in degrees [0, 360). Convert to hours. + const ra = ((lon + 360) % 360) / 15; + const dec = lat; + + const entry = RAW_NAMES[String(hip)]; + STARS_BY_HIP.set(hip, { + hip, + name: entry?.name || undefined, + bayer: entry?.bayer || undefined, + constellation: entry?.c || undefined, + ra, + dec, + mag, + }); +} + +/** Resolve a star by HIP number. Returns undefined if unknown. */ +export function starByHip(hip: number): Star | undefined { + return STARS_BY_HIP.get(hip); +} diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index a27c2016..745dbb22 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -68,6 +68,7 @@ const NOTIFICATION_KIND_NOUNS: Record = { 1222: 'voice message', 1617: 'patch', 1618: 'pull request', + 2473: 'bird detection', 3367: 'color moment', 7516: 'found log', 15128: 'nsite', @@ -96,6 +97,7 @@ const NOTIFICATION_KIND_NOUNS: Record = { 36787: 'track', 37381: 'Magic deck', 37516: 'treasure', + 30621: 'constellation', 39089: 'follow pack', }; diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index f6c74739..c45ca190 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -43,6 +43,8 @@ import { PeopleListContent } from "@/components/PeopleListContent"; import { PeopleListDetailContent } from "@/components/PeopleListDetailContent"; import { FoundLogContent } from "@/components/FoundLogContent"; import { GeocacheContent } from "@/components/GeocacheContent"; +import { BirdDetectionContent } from "@/components/BirdDetectionContent"; +import { ConstellationContent } from "@/components/ConstellationContent"; import { GitRepoCard } from "@/components/GitRepoCard"; import { ImageGallery } from "@/components/ImageGallery"; import { @@ -157,6 +159,8 @@ function shellTitleForKind(kind?: number): string { if (kind === 9735) return "Zap"; if (kind === 0) return "Profile"; if (kind === 31124) return "Blobbi"; + if (kind === 2473) return "Bird Detection"; + if (kind === 30621) return "Constellation"; return "Post Details"; } @@ -1010,6 +1014,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) { const isGeocache = event.kind === 37516; const isFoundLog = event.kind === 7516; const isColor = event.kind === 3367; + const isBirdDetection = event.kind === 2473; + const isConstellation = event.kind === 30621; const isPeopleList = event.kind === 3 || event.kind === 30000 || event.kind === 39089; const isEmojiPack = event.kind === 30030; const isArticle = event.kind === 30023; @@ -1046,6 +1052,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) { !isGeocache && !isFoundLog && !isColor && + !isBirdDetection && + !isConstellation && !isPeopleList && !isEmojiPack && !isArticle && @@ -2174,6 +2182,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) { isGeocache || isFoundLog || isColor || + isBirdDetection || + isConstellation || isPeopleList || isEmojiPack ? ( <> @@ -2182,6 +2192,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) { {isGeocache && } {isFoundLog && } {isColor && } + {isBirdDetection && } + {isConstellation && } {isPeopleList && } {isEmojiPack && } diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index fbb2f580..a3641877 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -92,6 +92,9 @@ export function TestApp({ children }: TestAppProps) { feedIncludeBadgeAwards: false, feedIncludeVanish: true, feedIncludeBlobbi: true, + showBirdstar: false, + feedIncludeBirdDetections: false, + feedIncludeConstellations: false, followsFeedShowReplies: true, }, sidebarOrder: [], diff --git a/tsconfig.json b/tsconfig.json index a7596daf..8749c5ef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "useDefineForClassFields": true, "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", + "resolveJsonModule": true, "skipLibCheck": true, /* Bundler mode */