Examples & Recipes
Real-world implementation patterns and code examples for common Sightline use cases.
Data Adapter Examples
Static JSON Adapter
The simplest setup using a local JSON file.
import type { MapConfig } from './src/core/config';
const config: Partial<MapConfig> = {
dataSources: [
{
id: 'local-incidents',
name: 'Local Data',
description: 'Incidents from static JSON file',
adapter: 'static',
adapterConfig: {
path: '/data/incidents.json',
},
enabled: true,
color: '#3b82f6',
},
],
};
export default config;{
"incidents": [
{
"id": "inc-001",
"status": "published",
"createdAt": "2024-01-15T00:00:00Z",
"updatedAt": "2024-01-15T00:00:00Z",
"summary": "Bright lights observed over downtown area",
"temporal": {
"date": "2024-01-15",
"dateCertainty": "exact",
"time": "21:30:00",
"timeCertainty": "approximate"
},
"location": {
"name": "Phoenix, Arizona",
"country": "United States",
"latitude": 33.4484,
"longitude": -112.074,
"siteType": "urban",
"locationSensitivity": "standard"
},
"classification": {
"incidentType": "sighting"
}
}
]
}Supabase Adapter
Connect to a Supabase database for production data.
const config: Partial<MapConfig> = {
dataSources: [
{
id: 'supabase-main',
name: 'Production Database',
adapter: 'supabase',
adapterConfig: {
// Uses NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
},
enabled: true,
color: '#10b981',
},
],
};NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...Supabase table schema:
create table incidents (
id uuid primary key default gen_random_uuid(),
status text not null default 'draft',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
summary text,
temporal jsonb not null,
location jsonb not null,
classification jsonb,
witnesses jsonb,
sensor_evidence jsonb,
media jsonb
);
-- Enable RLS
alter table incidents enable row level security;
-- Allow anonymous reads for published incidents
create policy "Public can read published incidents"
on incidents for select
using (status = 'published');REST API Adapter
Connect to an external API.
const config: Partial<MapConfig> = {
dataSources: [
{
id: 'external-api',
name: 'External Data Source',
adapter: 'api',
adapterConfig: {
endpoint: 'https://api.example.com/incidents',
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`,
},
params: {
status: 'published',
limit: '1000',
},
},
enabled: true,
color: '#f59e0b',
},
],
};The API adapter expects responses in the Sightline incident format. For custom APIs, you may need to transform the response—see Custom Adapter below.
Multiple Data Sources
Combine multiple sources with distinct styling.
const config: Partial<MapConfig> = {
dataSources: [
{
id: 'verified',
name: 'Verified Reports',
adapter: 'supabase',
adapterConfig: {}, // Uses default env vars
enabled: true,
color: '#10b981', // Green
},
{
id: 'community',
name: 'Community Reports',
adapter: 'static',
adapterConfig: { path: '/data/community.json' },
enabled: true,
color: '#6366f1', // Purple
},
{
id: 'historical',
name: 'Historical Archive',
adapter: 'api',
adapterConfig: { endpoint: 'https://archive.example.com/api' },
enabled: false, // Disabled by default
color: '#64748b', // Gray
},
],
};Custom Adapter
Create a custom adapter for non-standard data sources.
import type { DataAdapter, Incident, DataSourceConfig, IncidentFilters, FilterOptions } from './types';
interface CustomApiResponse {
data: Array<{
uuid: string;
event_date: string;
lat: number;
lng: number;
place_name: string;
description: string;
category: string;
}>;
total: number;
}
export class CustomAdapter implements DataAdapter {
readonly id: string;
readonly name: string;
private endpoint: string;
constructor(config: DataSourceConfig) {
this.id = config.id;
this.name = config.name;
this.endpoint = config.adapterConfig.endpoint as string;
}
async getIncidents(filters?: IncidentFilters): Promise<Incident[]> {
const response = await fetch(this.endpoint);
const data: CustomApiResponse = await response.json();
// Transform to Sightline format
return data.data.map((item) => ({
id: item.uuid,
status: 'published' as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
summary: item.description,
temporal: {
date: item.event_date,
dateCertainty: 'exact' as const,
},
location: {
name: item.place_name,
country: 'Unknown',
latitude: item.lat,
longitude: item.lng,
siteType: 'urban' as const,
locationSensitivity: 'standard' as const,
},
classification: {
incidentType: this.mapCategory(item.category),
},
}));
}
async getIncidentById(id: string): Promise<Incident | null> {
const incidents = await this.getIncidents();
return incidents.find((i) => i.id === id) || null;
}
async getFilterOptions(): Promise<FilterOptions> {
const incidents = await this.getIncidents();
return {
countries: [...new Set(incidents.map((i) => i.location.country))],
siteTypes: [...new Set(incidents.map((i) => i.location.siteType))],
incidentTypes: [...new Set(incidents.map((i) => i.classification?.incidentType).filter(Boolean))],
};
}
private mapCategory(category: string): string {
const mapping: Record<string, string> = {
'visual': 'sighting',
'contact': 'encounter',
'trace': 'physical_trace',
};
return mapping[category] || 'other';
}
}Register the adapter:
import { CustomAdapter } from './custom-adapter';
export function createAdapter(config: DataSourceConfig): DataAdapter {
switch (config.adapter) {
case 'custom':
return new CustomAdapter(config);
// ... other adapters
}
}Custom Renderers
Severity-Based Markers
Color markers based on incident severity.
'use client';
import type { MarkerRendererProps } from '@disclosureos/sightline-core';
const severityColors = {
critical: 'bg-red-500 border-red-700',
high: 'bg-orange-500 border-orange-700',
moderate: 'bg-yellow-500 border-yellow-700',
low: 'bg-blue-500 border-blue-700',
};
export function SeverityMarker({ incidents, isSelected, onClick }: MarkerRendererProps) {
const incident = incidents[0]; // Use primary incident
const sensitivity = incident.location?.locationSensitivity || 'standard';
const colorClass = severityColors[sensitivity as keyof typeof severityColors] || severityColors.low;
return (
<button
onClick={onClick}
className={`
w-4 h-4 rounded-full border-2 shadow-lg
transition-all duration-200
${colorClass}
${isSelected ? 'scale-150 ring-2 ring-white ring-offset-2' : 'hover:scale-125'}
`}
aria-label={`Incident at ${incident.location?.name}`}
/>
);
}Popup with Media Gallery
Rich popup with image/video previews.
'use client';
import { useState } from 'react';
import { X, ChevronLeft, ChevronRight, MapPin, Calendar } from 'lucide-react';
import type { PopupRendererProps } from '@disclosureos/sightline-core';
export function MediaPopup({ incident, onClose, onViewDetails }: PopupRendererProps) {
const [activeMedia, setActiveMedia] = useState(0);
const media = incident.media || [];
return (
<div className="bg-card rounded-lg shadow-xl max-w-md overflow-hidden">
{/* Media Carousel */}
{media.length > 0 && (
<div className="relative aspect-video bg-muted">
{media[activeMedia].type === 'video' ? (
<video
src={media[activeMedia].url}
controls
className="w-full h-full object-cover"
/>
) : (
<img
src={media[activeMedia].thumbnailUrl || media[activeMedia].url}
alt={media[activeMedia].caption || 'Incident media'}
className="w-full h-full object-cover"
/>
)}
{/* Navigation */}
{media.length > 1 && (
<>
<button
onClick={() => setActiveMedia((i) => (i - 1 + media.length) % media.length)}
className="absolute left-2 top-1/2 -translate-y-1/2 p-1 bg-black/50 rounded-full"
>
<ChevronLeft className="w-5 h-5 text-white" />
</button>
<button
onClick={() => setActiveMedia((i) => (i + 1) % media.length)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 bg-black/50 rounded-full"
>
<ChevronRight className="w-5 h-5 text-white" />
</button>
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1">
{media.map((_, i) => (
<button
key={i}
onClick={() => setActiveMedia(i)}
className={`w-2 h-2 rounded-full ${i === activeMedia ? 'bg-white' : 'bg-white/50'}`}
/>
))}
</div>
</>
)}
</div>
)}
{/* Content */}
<div className="p-4">
<div className="flex justify-between items-start">
<div>
<h3 className="font-semibold text-lg">{incident.location?.name}</h3>
<div className="flex items-center gap-4 text-sm text-muted-foreground mt-1">
<span className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
{incident.temporal?.date}
</span>
<span className="flex items-center gap-1">
<MapPin className="w-4 h-4" />
{incident.location?.country}
</span>
</div>
</div>
<button onClick={onClose} className="p-1 hover:bg-muted rounded">
<X className="w-5 h-5" />
</button>
</div>
{incident.summary && (
<p className="mt-3 text-sm line-clamp-3">{incident.summary}</p>
)}
{onViewDetails && (
<button
onClick={onViewDetails}
className="mt-4 w-full py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90"
>
View Full Details
</button>
)}
</div>
</div>
);
}Compact Feed Item with Badges
Minimal feed item with classification badges.
'use client';
import { MapPin } from 'lucide-react';
import type { FeedItemRendererProps } from '@disclosureos/sightline-core';
const classificationLabels: Record<string, string> = {
ce1: 'CE-I',
ce2: 'CE-II',
ce3: 'CE-III',
nl: 'NL',
dd: 'DD',
rv: 'RV',
};
export function BadgeFeedItem({ incident, isSelected, onClick, dataSource }: FeedItemRendererProps) {
const hynek = incident.classification?.hynekClassification;
const hasVideo = incident.media?.some((m) => m.type === 'video');
const hasPhoto = incident.media?.some((m) => m.type === 'image');
return (
<button
onClick={onClick}
className={`
w-full text-left p-3 rounded-lg border transition-all
${isSelected
? 'bg-primary/10 border-primary shadow-sm'
: 'border-border hover:bg-muted/50'
}
`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
{dataSource?.color && (
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: dataSource.color }}
/>
)}
<span className="font-medium truncate">{incident.location?.name}</span>
</div>
{/* Badges */}
<div className="flex gap-1 flex-shrink-0">
{hynek && (
<span className="px-1.5 py-0.5 text-xs bg-blue-500/20 text-blue-600 dark:text-blue-400 rounded">
{classificationLabels[hynek] || hynek.toUpperCase()}
</span>
)}
{hasVideo && (
<span className="px-1.5 py-0.5 text-xs bg-purple-500/20 text-purple-600 dark:text-purple-400 rounded">
Video
</span>
)}
{hasPhoto && !hasVideo && (
<span className="px-1.5 py-0.5 text-xs bg-green-500/20 text-green-600 dark:text-green-400 rounded">
Photo
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
<span>{incident.temporal?.date}</span>
<span>•</span>
<span className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
{incident.location?.country}
</span>
</div>
</button>
);
}Complete Page Layout Example
Custom page layout with all controls.
'use client';
import { useState } from 'react';
import { Menu, X } from 'lucide-react';
import { DataProvider, MapEngine, useData, useTheme } from '@/core';
import { FilterSidebar, TimelineControl, IncidentFeed, ThemeToggle, SourceToggle, StatsPanel } from '@/controls';
import { PulseMarker, MediaPopup, BadgeFeedItem } from '@/examples/renderers';
function MapPage() {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [isTimelineOpen, setIsTimelineOpen] = useState(true);
const { incidents, filteredIncidents, isLoading, error } = useData();
const { resolvedTheme } = useTheme();
// Track map ready state for unified initialization
const [mapReady, setMapReady] = useState(false);
const appReady = !isLoading && mapReady;
if (error) {
return (
<div className="h-screen flex items-center justify-center">
<div className="text-center">
<h2 className="text-xl font-semibold">Error loading data</h2>
<p className="text-muted-foreground mt-2">{error.message}</p>
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col">
{/* Header */}
<header className="h-14 border-b flex items-center justify-between px-4">
<div className="flex items-center gap-4">
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="p-2 hover:bg-muted rounded-lg lg:hidden"
>
{isSidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
<h1 className="font-semibold">My Sightline Map</h1>
</div>
<div className="flex items-center gap-2">
<SourceToggle />
<ThemeToggle />
</div>
</header>
{/* Main Content */}
<div className="flex-1 flex overflow-hidden">
{/* Left Sidebar - Filters */}
<aside
className={`
w-80 border-r overflow-y-auto
${isSidebarOpen ? 'block' : 'hidden lg:block'}
`}
>
<FilterSidebar />
</aside>
{/* Map */}
<main className="flex-1 relative">
<MapEngine
incidents={filteredIncidents}
renderMarker={(props) => <PulseMarker {...props} />}
renderPopup={(props) => <MediaPopup {...props} />}
onMapReady={() => setMapReady(true)}
/>
{/* Stats Overlay */}
<div className="absolute top-4 right-4">
<StatsPanel incidents={filteredIncidents} totalIncidents={incidents.length} />
</div>
{/* Timeline */}
{isTimelineOpen && (
<div className="absolute bottom-0 left-0 right-0">
<TimelineControl />
</div>
)}
</main>
{/* Right Sidebar - Feed */}
<aside className="w-96 border-l overflow-hidden hidden xl:flex flex-col">
<IncidentFeed
renderFeedItem={(props) => <BadgeFeedItem {...props} />}
/>
</aside>
</div>
</div>
);
}
export default function Page() {
return (
<DataProvider>
<MapPage />
</DataProvider>
);
}Filter Presets
Create quick filter presets for common queries.
'use client';
import { useData } from '@/core';
const presets = [
{
id: 'recent-verified',
name: 'Recent Verified',
filters: {
dateRange: {
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0],
},
multipleWitnesses: true,
},
},
{
id: 'close-encounters',
name: 'Close Encounters',
filters: {
hynekClassifications: ['ce1', 'ce2', 'ce3'],
},
},
{
id: 'military-sites',
name: 'Military Sites',
filters: {
siteTypes: ['military_base', 'airport_military'],
locationSensitivities: ['critical', 'high'],
},
},
{
id: 'with-evidence',
name: 'With Evidence',
filters: {
radarConfirmed: true,
physicalEvidence: true,
},
},
];
export function FilterPresets() {
const { updateFilter, resetFilters } = useData();
const applyPreset = (preset: typeof presets[0]) => {
resetFilters();
Object.entries(preset.filters).forEach(([key, value]) => {
updateFilter(key as any, value);
});
};
return (
<div className="flex flex-wrap gap-2 p-4">
<button
onClick={resetFilters}
className="px-3 py-1.5 text-sm border rounded-full hover:bg-muted"
>
Clear All
</button>
{presets.map((preset) => (
<button
key={preset.id}
onClick={() => applyPreset(preset)}
className="px-3 py-1.5 text-sm bg-primary/10 text-primary rounded-full hover:bg-primary/20"
>
{preset.name}
</button>
))}
</div>
);
}