Render Birdstar bird detections and custom constellations

Adds feed support for kind 2473 (bird-by-ear detections) and kind 30621
(user-drawn star figures) from Birdstar. Detections render as species
cards using the existing Wikidata + Wikipedia summary hooks; constellations
render as gnomonically-projected SVG star-maps backed by the Hipparcos
catalog from d3-celestial. The 1.1 MB catalog is code-split via lazy() so
it only loads when a constellation event is actually viewed.
This commit is contained in:
Alex Gleason
2026-04-28 03:12:24 -05:00
parent 480e0aa97f
commit c957041cf3
20 changed files with 826 additions and 4 deletions
+13
View File
@@ -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
+16
View File
@@ -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",
+1
View File
@@ -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",
+3
View File
@@ -112,6 +112,9 @@ const hardcodedConfig: AppConfig = {
feedIncludeBadgeAwards: true,
feedIncludeVanish: true,
feedIncludeBlobbi: true,
showBirdstar: true,
feedIncludeBirdDetections: true,
feedIncludeConstellations: true,
followsFeedShowReplies: true,
},
sidebarOrder: [
+175
View File
@@ -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: <Common> (<Scientific>)" — 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 (
<div className={cn('mt-2 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground', className)}>
Bird detection with no species reference.
</div>
);
}
return (
<div className={cn('mt-2', className)}>
<div className="flex overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md">
{/* Thumbnail panel */}
<div className="relative w-32 shrink-0 bg-gradient-to-br from-emerald-100 via-sky-100 to-amber-100 sm:w-40 dark:from-indigo-950 dark:via-indigo-900 dark:to-amber-900/40">
{isLoading ? (
<Skeleton className="h-full w-full" />
) : thumbnail ? (
<img
src={thumbnail}
alt={commonName}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Bird
aria-hidden
strokeWidth={1.5}
className="size-10 text-emerald-700/60 dark:text-amber-300/60"
/>
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-black/40 to-transparent" />
<div className="absolute bottom-1.5 left-2 font-mono text-[10px] uppercase tracking-wider text-white/85">
{timeStr}
</div>
</div>
{/* Text panel */}
<div className="flex min-w-0 flex-1 flex-col gap-1.5 p-3.5">
<div className="min-w-0">
<div className="flex items-start gap-1.5">
<Bird aria-hidden className="mt-0.5 size-3.5 shrink-0 text-emerald-600 dark:text-amber-300" />
<h3 className="truncate text-[15px] font-semibold leading-tight">
{commonName}
</h3>
</div>
{scientificName && (
<p className="mt-0.5 truncate pl-5 text-xs italic text-muted-foreground">
{scientificName}
</p>
)}
</div>
{isLoading ? (
<div className="space-y-1.5 pt-0.5">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-2/3" />
</div>
) : extract ? (
<p className="line-clamp-3 text-[13px] leading-relaxed text-muted-foreground">
{extract}
</p>
) : (
<p className="text-xs italic text-muted-foreground/70">
Heard at {new Date(event.created_at * 1000).toLocaleString()}.
</p>
)}
{articleUrl && (
<a
href={articleUrl}
target="_blank"
rel="noreferrer noopener"
onClick={(e) => e.stopPropagation()}
className="inline-flex w-fit items-center gap-1 pt-0.5 text-[11px] font-medium text-muted-foreground hover:text-foreground"
>
<ExternalLink className="size-3" />
Wikipedia
</a>
)}
</div>
</div>
{note && (
<p className="mt-2 text-[15px] leading-relaxed whitespace-pre-wrap break-words">
{note}
</p>
)}
</div>
);
}
+7 -2
View File
@@ -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<number, string> = {
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<number, string> = {
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<Record<number, React.ComponentType<{ className?: strin
3367: Palette,
9735: Zap,
31124: Egg,
2473: Bird,
30621: Stars,
};
/**
@@ -233,6 +237,7 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
39089: 'follow pack',
37381: 'deck',
37516: 'treasure',
30621: 'constellation',
34550: 'community',
30054: 'episode',
30055: 'trailer',
+91
View File
@@ -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<readonly [number, number]>;
}
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<readonly [number, number]> = [];
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 (
<div className={cn('mt-2', className)}>
<div className="overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md">
{/* Star map */}
<div className="aspect-[4/3] w-full">
<Suspense fallback={<Skeleton className="size-full" />}>
<ConstellationStarMap edges={edges} title={title} />
</Suspense>
</div>
{/* Title + description */}
<div className="space-y-1.5 p-3.5">
<div className="flex items-center gap-2">
<Sparkles aria-hidden className="size-3.5 shrink-0 text-amber-500" />
<h3 className="truncate text-[15px] font-semibold leading-tight">
{title}
</h3>
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground">
{edges.length} {edges.length === 1 ? 'edge' : 'edges'}
</span>
</div>
{description && (
<p className="whitespace-pre-wrap break-words text-[13px] leading-relaxed text-muted-foreground">
{description}
</p>
)}
</div>
</div>
</div>
);
}
+322
View File
@@ -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<readonly [number, number]>;
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<number, ProjectedPoint>;
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 <filter> 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 (
<div
className={cn(
'flex size-full items-center justify-center rounded-xl ring-1 ring-border bg-[radial-gradient(ellipse_at_50%_40%,#1e1b4b_0%,#0b1026_55%,#020617_100%)] text-xs text-white/60',
className,
)}
role="img"
aria-label={title ?? 'Constellation preview'}
>
No recognizable stars.
</div>
);
}
const { points, edges: projEdges, backgroundStars } = projected;
return (
<div
className={cn(
'relative h-full w-full overflow-hidden rounded-xl ring-1 ring-border',
'bg-[radial-gradient(ellipse_at_50%_40%,#1e1b4b_0%,#0b1026_55%,#020617_100%)]',
className,
)}
role="img"
aria-label={title ?? 'Constellation preview'}
>
{/* Background field stars — cover the whole container regardless of
aspect ratio, so corners never look bare. */}
<svg
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid slice"
className="absolute inset-0 size-full"
aria-hidden
>
<g fill="rgba(255, 255, 255, 0.5)">
{backgroundStars.map((s, i) => (
<circle key={i} cx={s.x} cy={s.y} r={s.r} opacity={s.o} />
))}
</g>
</svg>
{/* Figure — preserves aspect so stick-figures never distort. */}
<svg
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid meet"
className="absolute inset-0 size-full"
>
<defs>
<filter
id={glowId}
x="-100%"
y="-100%"
width="300%"
height="300%"
colorInterpolationFilters="sRGB"
>
<feGaussianBlur stdDeviation="1.1" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Edges */}
<g
stroke="rgba(253, 230, 138, 0.8)"
strokeWidth={0.9}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
pointerEvents="none"
>
{projEdges.map((e, i) => (
<line key={i} x1={e.x1} y1={e.y1} x2={e.x2} y2={e.y2} />
))}
</g>
{/* Figure stars with soft glow */}
<g fill="rgb(254, 243, 199)" filter={`url(#${glowId})`}>
{Array.from(points.values()).map((p) => (
<circle key={p.hip} cx={p.x} cy={p.y} r={p.r} pointerEvents="none" />
))}
</g>
</svg>
</div>
);
}
function project(edges: ReadonlyArray<readonly [number, number]>): 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<number, ResolvedStar>();
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<number, { x: number; y: number; mag: number }>();
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<number, ProjectedPoint>();
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<readonly [number, number]>,
figure: Map<number, ProjectedPoint>,
): 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;
}
+5 -1
View File
@@ -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<number, string> = {
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]);
+22
View File
@@ -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({
<FoundLogContent event={event} />
) : isColor ? (
<ColorMomentContent event={event} />
) : isBirdDetection ? (
<BirdDetectionContent event={event} />
) : isConstellation ? (
<ConstellationContent event={event} />
) : isPeopleList ? (
<PeopleListContent event={event} />
) : isArticle ? (
@@ -1834,6 +1846,16 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
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",
+6
View File
@@ -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;
}
+28 -1
View File
@@ -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<Record<number, ComponentType<{ className?: st
15128: Globe,
35128: Globe,
30817: CircleAlert,
2473: Bird,
30621: Stars,
};
/**
+11
View File
@@ -95,6 +95,17 @@ export function shouldHideFeedEvent(event: NostrEvent): boolean {
if (event.kind === 37516 && event.tags.some(([n, v]) => 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<digits>.
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;
}
+3
View File
@@ -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). */
+4
View File
@@ -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<string, IconComponent> = {
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]),
),
+101
View File
@@ -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<string, StarNameEntry>;
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<number, Star>();
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);
}
+2
View File
@@ -68,6 +68,7 @@ const NOTIFICATION_KIND_NOUNS: Record<number, string> = {
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<number, string> = {
36787: 'track',
37381: 'Magic deck',
37516: 'treasure',
30621: 'constellation',
39089: 'follow pack',
};
+12
View File
@@ -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 && <GeocacheContent event={event} />}
{isFoundLog && <FoundLogContent event={event} />}
{isColor && <ColorMomentContent event={event} />}
{isBirdDetection && <BirdDetectionContent event={event} />}
{isConstellation && <ConstellationContent event={event} />}
{isPeopleList && <PeopleListContent event={event} />}
{isEmojiPack && <EmojiPackContent event={event} />}
</>
+3
View File
@@ -92,6 +92,9 @@ export function TestApp({ children }: TestAppProps) {
feedIncludeBadgeAwards: false,
feedIncludeVanish: true,
feedIncludeBlobbi: true,
showBirdstar: false,
feedIncludeBirdDetections: false,
feedIncludeConstellations: false,
followsFeedShowReplies: true,
},
sidebarOrder: [],
+1
View File
@@ -4,6 +4,7 @@
"useDefineForClassFields": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"resolveJsonModule": true,
"skipLibCheck": true,
/* Bundler mode */