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:
@@ -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
|
||||
|
||||
Generated
+16
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -112,6 +112,9 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeBadgeAwards: true,
|
||||
feedIncludeVanish: true,
|
||||
feedIncludeBlobbi: true,
|
||||
showBirdstar: true,
|
||||
feedIncludeBirdDetections: true,
|
||||
feedIncludeConstellations: true,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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]),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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} />}
|
||||
</>
|
||||
|
||||
@@ -92,6 +92,9 @@ export function TestApp({ children }: TestAppProps) {
|
||||
feedIncludeBadgeAwards: false,
|
||||
feedIncludeVanish: true,
|
||||
feedIncludeBlobbi: true,
|
||||
showBirdstar: false,
|
||||
feedIncludeBirdDetections: false,
|
||||
feedIncludeConstellations: false,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
|
||||
Reference in New Issue
Block a user