Add map and KJV audio playback UI

This commit is contained in:
2026-07-15 11:54:52 -05:00
parent a1a902c938
commit 02ffa1dfc4
8 changed files with 1080 additions and 77 deletions
+236 -2
View File
@@ -741,7 +741,9 @@ textarea:focus-visible {
}
.commentary-card,
.dictionary-card {
.dictionary-card,
.map-card,
.audio-card {
grid-column: 1 / -1;
}
@@ -787,6 +789,211 @@ textarea:focus-visible {
text-transform: uppercase;
}
.map-card {
gap: 8px;
}
.context-map {
position: relative;
min-height: 190px;
overflow: hidden;
border: 1px solid #d9cfbd;
border-radius: 6px;
background:
linear-gradient(135deg, rgba(47, 111, 94, 0.12), transparent 34%),
linear-gradient(0deg, rgba(214, 168, 79, 0.18), transparent 42%),
#eef1e6;
}
.map-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(68, 76, 65, 0.12) 1px, transparent 1px),
linear-gradient(90deg, rgba(68, 76, 65, 0.12) 1px, transparent 1px);
background-size: 25% 25%;
}
.map-marker {
position: absolute;
z-index: 2;
display: grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: 2px solid #fffef8;
border-radius: 50%;
background: #2f6f5e;
color: #fffef8;
font-size: 11px;
font-weight: 900;
line-height: 1;
transform: translate(-50%, -50%);
box-shadow: 0 2px 7px rgba(22, 26, 20, 0.26);
}
.map-marker.selected {
width: 30px;
height: 30px;
background: #d6a84f;
color: #141510;
}
.place-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.place-list section {
display: grid;
gap: 2px;
min-width: 0;
padding: 7px;
border: 1px solid #e4dccd;
border-radius: 5px;
background: #fbf8ef;
}
.place-list h3 {
margin: 0;
overflow: hidden;
color: #252821;
font-size: 12px;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.place-list p {
display: grid;
gap: 2px;
margin: 0;
color: #657061;
font-size: 11px;
line-height: 1.25;
}
.place-list small {
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
}
.route-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
color: #657061;
font-size: 11px;
font-weight: 800;
}
.route-list span {
display: inline-flex;
gap: 5px;
align-items: center;
max-width: 100%;
padding: 4px 6px;
border: 1px solid #d8cfbf;
border-radius: 999px;
background: #fbf8ef;
color: #252821;
font-size: 11px;
overflow-wrap: anywhere;
}
.route-list small {
color: #657061;
font-size: 10px;
}
.map-attribution {
margin: 0;
color: #657061;
font-size: 11px;
line-height: 1.35;
}
.audio-card {
gap: 8px;
}
.audio-controls {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
}
.audio-controls button {
min-width: 0;
min-height: 30px;
padding: 5px 7px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audio-controls .secondary-button {
border-color: #d8cfbf;
background: #fbf8ef;
color: #252821;
}
.audio-sliders {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(90px, 0.45fr);
gap: 8px;
align-items: end;
}
.audio-sliders label {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
min-width: 0;
color: #657061;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.audio-sliders select {
grid-column: 2 / 4;
}
.audio-sliders input[type='range'] {
padding: 0;
}
.audio-status {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
margin: 0;
color: #3f443d;
font-size: 12px;
line-height: 1.35;
}
.audio-status strong {
flex: 0 0 auto;
color: #657061;
font-size: 11px;
text-transform: uppercase;
}
.audio-status span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notes-workspace {
display: flex;
flex-direction: column;
@@ -1381,11 +1588,28 @@ textarea:focus-visible {
.theme-dark .original-language-verse,
.theme-dark .english-strong-line,
.theme-dark .resource-card {
.theme-dark .resource-card,
.theme-dark .place-list section,
.theme-dark .route-list span,
.theme-dark .audio-controls .secondary-button {
border-color: #3f493f;
background: #20251f;
}
.theme-dark .context-map {
border-color: #3f493f;
background:
linear-gradient(135deg, rgba(58, 122, 105, 0.22), transparent 34%),
linear-gradient(0deg, rgba(199, 150, 63, 0.2), transparent 42%),
#1c241e;
}
.theme-dark .map-grid {
background-image:
linear-gradient(rgba(199, 213, 190, 0.13) 1px, transparent 1px),
linear-gradient(90deg, rgba(199, 213, 190, 0.13) 1px, transparent 1px);
}
.theme-dark .margin-section-header {
background: #2b332a;
}
@@ -1420,6 +1644,14 @@ textarea:focus-visible {
.theme-dark .timeline-canvas small,
.theme-dark .note-helper small,
.theme-dark .note-placeholder small,
.theme-dark .place-list p,
.theme-dark .place-list small,
.theme-dark .route-list,
.theme-dark .route-list small,
.theme-dark .map-attribution,
.theme-dark .audio-sliders label,
.theme-dark .audio-status,
.theme-dark .audio-status strong,
.theme-dark .session-facts dt,
.theme-dark .about-list dt,
.theme-dark .modal-section label,
@@ -1431,6 +1663,8 @@ textarea:focus-visible {
.theme-dark .strong-focus p,
.theme-dark .commentary-list h3,
.theme-dark .dictionary-list h3,
.theme-dark .place-list h3,
.theme-dark .route-list span,
.theme-dark .commentary-list p,
.theme-dark .dictionary-list p,
.theme-dark .linked-line-text,
+352 -1
View File
@@ -2,8 +2,11 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import {
addTagToVerse,
getCommentaryForVerse,
getAudioContextForVerse,
getAudioObjectUrl,
getDictionaryEntriesForTerms,
getLibraryState,
getMapContextForVerses,
getTranslationNotesForVerses,
getTranslations,
searchVerses,
@@ -18,7 +21,15 @@ import {
timelineLimit,
} from './workspace'
import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
import type { CommentaryEntry, DictionaryEntry, TranslationResource, Verse } from './types'
import type {
AudioBibleContext,
AudioBibleSegment,
CommentaryEntry,
DictionaryEntry,
MapContext,
TranslationResource,
Verse,
} from './types'
import type {
StrongDisplayMode,
StudySession,
@@ -221,7 +232,19 @@ function App() {
const [splitPercent, setSplitPercent] = useState(50)
const [commentaryEntries, setCommentaryEntries] = useState<CommentaryEntry[]>([])
const [dictionaryEntries, setDictionaryEntries] = useState<DictionaryEntry[]>([])
const [mapContext, setMapContext] = useState<MapContext>({
selectedPlaces: [],
pagePlaces: [],
relatedRoutes: [],
})
const [audioContext, setAudioContext] = useState<AudioBibleContext | null>(null)
const [audioResourcesLoading, setAudioResourcesLoading] = useState(false)
const [audioStatus, setAudioStatus] = useState('Ready')
const [audioPlayingSegmentId, setAudioPlayingSegmentId] = useState<string | null>(null)
const [audioVolume, setAudioVolume] = useState(0.85)
const [audioSpeed, setAudioSpeed] = useState(1)
const [marginResourcesLoading, setMarginResourcesLoading] = useState(false)
const [mapResourcesLoading, setMapResourcesLoading] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const restoredSessionId = useRef('')
@@ -229,6 +252,9 @@ function App() {
const notesDragOpenTimeout = useRef<number | null>(null)
const remoteWorkspaceSaveTimeout = useRef<number | null>(null)
const readerRef = useRef<HTMLElement | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const audioQueueRef = useRef<AudioBibleSegment[]>([])
const audioQueueIndexRef = useRef(0)
const hydratedNoteChapters = useRef(new Set<string>())
const workspaceHydrated = useRef(false)
@@ -603,6 +629,70 @@ function App() {
.filter((verse) => verse.book === book)
}, [libraryVerses, pageStartId, selectedVerse, verses])
useEffect(() => {
let cancelled = false
async function loadMapContext() {
if (!selectedVerse || pageVerses.length === 0) {
setMapContext({ selectedPlaces: [], pagePlaces: [], relatedRoutes: [] })
return
}
setMapResourcesLoading(true)
try {
const nextContext = await getMapContextForVerses(selectedVerse, pageVerses)
if (!cancelled) setMapContext(nextContext)
} catch (reason) {
if (!cancelled) setError(String(reason))
} finally {
if (!cancelled) setMapResourcesLoading(false)
}
}
loadMapContext()
return () => {
cancelled = true
}
}, [pageVerses, selectedVerse])
useEffect(() => {
let cancelled = false
async function loadAudioContext() {
if (!selectedVerse) {
setAudioContext(null)
return
}
setAudioResourcesLoading(true)
try {
const nextContext = await getAudioContextForVerse(
activeSession?.baseTranslation ?? defaultTranslationId,
selectedVerse,
)
if (!cancelled) setAudioContext(nextContext)
} catch (reason) {
if (!cancelled) {
setAudioContext(null)
setError(String(reason))
}
} finally {
if (!cancelled) setAudioResourcesLoading(false)
}
}
loadAudioContext()
return () => {
cancelled = true
}
}, [activeSession?.baseTranslation, selectedVerse])
useEffect(() => {
if (!audioRef.current) return
audioRef.current.volume = audioVolume
audioRef.current.playbackRate = audioSpeed
}, [audioSpeed, audioVolume])
useEffect(() => {
if (!activeSession?.baseTranslation || pageVerses.length === 0) return
const chapterKeys = [
@@ -985,6 +1075,69 @@ function App() {
})
}
async function playAudioQueue(segments: AudioBibleSegment[], startIndex = 0) {
if (!segments.length) return
audioQueueRef.current = segments
audioQueueIndexRef.current = startIndex
await playQueuedAudioSegment(startIndex)
}
async function playQueuedAudioSegment(index: number) {
const segment = audioQueueRef.current[index]
const audio = audioRef.current
if (!segment || !audio) return
setAudioResourcesLoading(true)
setAudioStatus(`Loading ${segment.reference}`)
try {
const url = await getAudioObjectUrl(segment)
audio.pause()
audio.src = url
audio.volume = audioVolume
audio.playbackRate = audioSpeed
await audio.play()
setAudioPlayingSegmentId(segment.id)
setAudioStatus(`Playing ${segment.reference}`)
recordTimeline({
type: 'setting',
label: `Audio: ${segment.reference}`,
reference: segment.reference,
detail: segment.audioZipFile,
})
} catch (reason) {
setAudioPlayingSegmentId(null)
setAudioStatus(`Audio unavailable: ${String(reason)}`)
} finally {
setAudioResourcesLoading(false)
}
}
function stopAudio() {
const audio = audioRef.current
audioQueueRef.current = []
audioQueueIndexRef.current = 0
if (audio) {
audio.pause()
audio.currentTime = 0
}
setAudioPlayingSegmentId(null)
setAudioStatus('Stopped')
}
function handleAudioEnded() {
const nextIndex = audioQueueIndexRef.current + 1
if (nextIndex < audioQueueRef.current.length) {
audioQueueIndexRef.current = nextIndex
playQueuedAudioSegment(nextIndex).catch((reason) => setAudioStatus(String(reason)))
return
}
audioQueueRef.current = []
audioQueueIndexRef.current = 0
setAudioPlayingSegmentId(null)
setAudioStatus('Ready')
}
function renderVerseText(verse: Verse) {
if (!activeSession?.strongsEnabled || !verse.strongs.length || strongDisplay === 'off') return verse.text
@@ -1355,6 +1508,202 @@ function App() {
)
}
function renderMapSection() {
const places = mapContext.pagePlaces
const selectedPlaces = mapContext.selectedPlaces
const routes = mapContext.relatedRoutes
const bounds = mapBounds(places)
return (
<article className="resource-card map-card" aria-label="Contextual maps">
<header className="margin-section-header">
<button type="button" className="collapse-button">Context Map</button>
<span>
{places.length
? `${places.length} places`
: mapResourcesLoading
? 'Loading'
: 'None'}
</span>
</header>
{places.length ? (
<>
<div className="context-map" aria-label="Visible-page place map">
<div className="map-grid" aria-hidden="true" />
{places.map((place) => {
const selected = selectedPlaces.some((item) => item.placeId === place.placeId)
const point = mapPoint(place, bounds)
return (
<button
key={place.id}
type="button"
className={selected ? 'map-marker selected' : 'map-marker'}
style={{ left: `${point.x}%`, top: `${point.y}%` }}
title={`${place.placeName}: ${place.modernLocationName}`}
>
<span>{place.placeName.slice(0, 1)}</span>
</button>
)
})}
</div>
<div className="place-list">
{places.slice(0, 8).map((place) => (
<section key={place.id}>
<h3>{place.placeName}</h3>
<p>
<span>{place.modernLocationName || 'Unresolved location'}</span>
<small>{place.confidenceLabel} confidence</small>
</p>
</section>
))}
</div>
{routes.length ? (
<div className="route-list">
<strong>Related routes</strong>
{routes.map((route) => (
<span key={route.id}>
{route.title}
<small>{route.mapNumber}</small>
</span>
))}
</div>
) : null}
<p className="map-attribution">
Places: OpenBible.info. Routes: UBS Project MARBLE. Coordinates are contextual and may be approximate.
</p>
</>
) : (
<p className="resource-disabled">
{mapResourcesLoading ? 'Loading contextual map data...' : 'No mapped places matched this page.'}
</p>
)}
</article>
)
}
function renderAudioSection() {
const selectedSegment = audioContext?.selectedSegment ?? null
const chapterSegments = audioContext?.chapterPlaylist?.segments ?? []
const selectedIndex = selectedSegment
? Math.max(0, chapterSegments.findIndex((segment) => segment.id === selectedSegment.id))
: 0
return (
<article className="resource-card audio-card" aria-label="Audio Bible">
<header className="margin-section-header">
<button type="button" className="collapse-button">Audio Bible</button>
<span>
{audioResourcesLoading
? 'Loading'
: audioContext
? audioContext.shortTitle
: 'None'}
</span>
</header>
{audioContext && selectedSegment ? (
<>
<div className="audio-controls">
<button
type="button"
onClick={() => playAudioQueue([selectedSegment]).catch((reason) => setAudioStatus(String(reason)))}
>
Play verse
</button>
<button
type="button"
onClick={() =>
playAudioQueue(chapterSegments, 0).catch((reason) => setAudioStatus(String(reason)))
}
>
Play chapter
</button>
<button
type="button"
onClick={() =>
playAudioQueue(chapterSegments, selectedIndex).catch((reason) => setAudioStatus(String(reason)))
}
>
From verse
</button>
<button type="button" className="secondary-button" onClick={stopAudio}>
Stop
</button>
</div>
<div className="audio-sliders">
<label>
Volume
<input
type="range"
min="0"
max="1"
step="0.05"
value={audioVolume}
onChange={(event) => setAudioVolume(Number(event.target.value))}
/>
<span>{Math.round(audioVolume * 100)}%</span>
</label>
<label>
Speed
<select
value={audioSpeed}
onChange={(event) => setAudioSpeed(Number(event.target.value))}
>
<option value="0.75">0.75x</option>
<option value="1">1x</option>
<option value="1.25">1.25x</option>
<option value="1.5">1.5x</option>
<option value="2">2x</option>
</select>
</label>
</div>
<p className="audio-status">
<strong>{audioPlayingSegmentId ? 'Playing' : 'Status'}</strong>
<span>{audioStatus}</span>
</p>
<p className="map-attribution">{audioContext.attribution}</p>
<audio ref={audioRef} preload="none" onEnded={handleAudioEnded} />
</>
) : (
<p className="resource-disabled">
{audioResourcesLoading
? 'Loading audio metadata...'
: 'No audio package is attached to this translation yet.'}
</p>
)}
</article>
)
}
function mapBounds(places: MapContext['pagePlaces']) {
const points = places.filter((place) => place.lon != null && place.lat != null)
if (points.length === 0) {
return { minLon: 0, maxLon: 1, minLat: 0, maxLat: 1 }
}
const minLon = Math.min(...points.map((place) => place.lon ?? 0))
const maxLon = Math.max(...points.map((place) => place.lon ?? 0))
const minLat = Math.min(...points.map((place) => place.lat ?? 0))
const maxLat = Math.max(...points.map((place) => place.lat ?? 0))
const lonPad = Math.max(0.2, (maxLon - minLon) * 0.18)
const latPad = Math.max(0.2, (maxLat - minLat) * 0.18)
return {
minLon: minLon - lonPad,
maxLon: maxLon + lonPad,
minLat: minLat - latPad,
maxLat: maxLat + latPad,
}
}
function mapPoint(place: MapContext['pagePlaces'][number], bounds: ReturnType<typeof mapBounds>) {
const lonRange = bounds.maxLon - bounds.minLon || 1
const latRange = bounds.maxLat - bounds.minLat || 1
return {
x: Math.max(5, Math.min(95, (((place.lon ?? bounds.minLon) - bounds.minLon) / lonRange) * 100)),
y: Math.max(5, Math.min(95, 100 - (((place.lat ?? bounds.minLat) - bounds.minLat) / latRange) * 100)),
}
}
function renderBiblePage(label = activeTranslation?.abbreviation ?? 'Bible') {
if (!selectedVerse) return null
return (
@@ -1476,6 +1825,8 @@ function App() {
)}
<div className="margin-resource-grid">
{renderStudyNotesSection()}
{renderAudioSection()}
{renderMapSection()}
{renderCommentarySection()}
{renderDictionarySection()}
</div>
+299
View File
@@ -1,10 +1,16 @@
import { invoke } from '@tauri-apps/api/core'
import JSZip from 'jszip'
import { buildVerseSearchIndex } from './searchIndex'
import type {
AudioBibleContext,
AudioBibleSegment,
CommentaryEntry,
BiblePlace,
BibleRoute,
DictionaryEntry,
Footnote,
LibraryState,
MapContext,
StrongLink,
Tag,
TranslationResource,
@@ -62,6 +68,21 @@ type ImportedCatalog = {
}>
}
type ImportedAudioChapterPlaylist = {
id: string
resource_id: string
translation_id: string
book_id: string
book: string
chapter: number
reference: string
audio_zip_file: string
audio_zip_url: string
segment_ids: string[]
audio_entry_paths: string[]
segment_count: number
}
type ImportedStrongEntry = {
strongs_number: string
language: 'hebrew' | 'greek'
@@ -102,6 +123,36 @@ type ImportedDictionaryEntry = {
sources: string[]
}
type ImportedVersePlace = {
verse_id: string
readable: string
place_id: string
place_name: string
place_types: string[]
modern_location_id: string
modern_location_name: string
lon: number | null
lat: number | null
confidence: number | null
confidence_label: string
}
type ImportedBibleRoute = {
id: string
route_number: number
title: string
section_number: string
section_title: string
map_number: string
geojson_file: string
bounds: {
min_lon: number
min_lat: number
max_lon: number
max_lat: number
} | null
}
type BrowserLexiconEntry = {
lemma: string
transliteration: string
@@ -123,7 +174,12 @@ const browserChapterStudyNotesCache = new Map<string, Promise<Map<string, Footno
const browserSearchIndexCache = new Map<string, Promise<ReturnType<typeof buildVerseSearchIndex>>>()
let browserCommentaryCache: Promise<Map<string, CommentaryEntry[]>> | null = null
let browserDictionaryCache: Promise<Map<string, DictionaryEntry>> | null = null
let browserVersePlacesCache: Promise<Map<string, BiblePlace[]>> | null = null
let browserRoutesCache: Promise<BibleRoute[]> | null = null
let browserAudioPlaylistCache: Promise<Map<string, ImportedAudioChapterPlaylist>> | null = null
const browserVerseSourceIds = new Map<number, string>()
const browserAudioZipCache = new Map<string, Promise<JSZip>>()
const browserAudioObjectUrlCache = new Map<string, Promise<string>>()
const fallbackBrowserVerses: Verse[] = [
{
@@ -416,6 +472,100 @@ export async function getDictionaryEntriesForTerms(terms: string[]): Promise<Dic
return entries
}
export async function getMapContextForVerses(
selectedVerse: Verse,
pageVerses: Verse[],
): Promise<MapContext> {
if (inTauri()) {
return { selectedPlaces: [], pagePlaces: [], relatedRoutes: [] }
}
const versePlaces = await loadBrowserVersePlaces()
const selectedVerseId = browserVerseKey(selectedVerse)
const pageVerseIds = new Set(pageVerses.map((verse) => browserVerseKey(verse)).filter(Boolean))
const selectedPlaces = uniquePlaces(versePlaces.get(selectedVerseId) ?? [])
const pagePlaces = uniquePlaces(
[...pageVerseIds].flatMap((verseId) => versePlaces.get(verseId) ?? []),
)
const relatedRoutes = await relatedRoutesForPlaces(pagePlaces)
return {
selectedPlaces,
pagePlaces,
relatedRoutes,
}
}
export async function getAudioContextForVerse(
translationId: string,
verse: Verse,
): Promise<AudioBibleContext | null> {
if (inTauri()) return null
const catalog = await loadBrowserCatalog()
const resource = catalog.resources.find(
(item) =>
item.resource_type === 'audio_bible' &&
item.id === 'kjv-audio-eliranwong' &&
translationId === defaultTranslationId,
)
if (!resource) return null
const verseId = browserVerseKey(verse)
const [bookId, chapter] = verseId.split('.')
if (!bookId || !chapter) return null
const playlists = await loadBrowserAudioPlaylists()
const playlist = playlists.get(`${bookId}.${chapter}`) ?? null
const selectedIndex = playlist?.segment_ids.findIndex((id) => id.endsWith(`:${verseId}`)) ?? -1
const licenseName = resource.license?.name ?? ''
const attribution = resource.license?.attribution ?? ''
return {
resourceId: resource.id,
title: resource.title,
shortTitle: resource.short_title ?? resource.title,
licenseName,
attribution,
selectedSegment:
playlist && selectedIndex >= 0
? mapAudioSegment(playlist, selectedIndex, licenseName, attribution)
: null,
chapterPlaylist: playlist
? {
id: playlist.id,
resourceId: playlist.resource_id,
translationId: playlist.translation_id,
book: playlist.book,
chapter: playlist.chapter,
reference: playlist.reference,
segments: playlist.segment_ids.map((_, index) =>
mapAudioSegment(playlist, index, licenseName, attribution),
),
}
: null,
}
}
export async function getAudioObjectUrl(segment: AudioBibleSegment): Promise<string> {
const cacheKey = `${segment.audioZipUrl}#${segment.audioEntryPath}`
if (!browserAudioObjectUrlCache.has(cacheKey)) {
browserAudioObjectUrlCache.set(
cacheKey,
loadAudioZip(segment.audioZipUrl).then(async (zip) => {
const file = zip.file(segment.audioEntryPath)
if (!file) {
throw new Error(`Audio entry not found in ${segment.audioZipFile}: ${segment.audioEntryPath}`)
}
const blob = await file.async('blob')
return URL.createObjectURL(blob)
}),
)
}
return browserAudioObjectUrlCache.get(cacheKey)!
}
async function loadBrowserCatalog(): Promise<ImportedCatalog> {
browserCatalogCache ??= fetchJson<ImportedCatalog>(`${libreBibleDevRoot}/catalog.json`)
return browserCatalogCache
@@ -499,6 +649,155 @@ async function loadBrowserDictionary(): Promise<Map<string, DictionaryEntry>> {
return browserDictionaryCache
}
async function loadBrowserVersePlaces(): Promise<Map<string, BiblePlace[]>> {
browserVersePlacesCache ??= loadLibreBibleDevVersePlaces()
return browserVersePlacesCache
}
async function loadLibreBibleDevVersePlaces(): Promise<Map<string, BiblePlace[]>> {
const importedEntries = await fetchJsonl<ImportedVersePlace>(
`${libreBibleDevRoot}/biblical-places-openbible/verse-places.jsonl`,
)
const places = new Map<string, BiblePlace[]>()
for (const entry of importedEntries) {
const mapped: BiblePlace = {
id: `${entry.verse_id}:${entry.place_id}`,
verseId: entry.verse_id,
readable: entry.readable,
placeId: entry.place_id,
placeName: entry.place_name,
placeTypes: entry.place_types,
modernLocationId: entry.modern_location_id,
modernLocationName: entry.modern_location_name,
lon: entry.lon,
lat: entry.lat,
confidence: entry.confidence,
confidenceLabel: entry.confidence_label,
}
const current = places.get(mapped.verseId) ?? []
current.push(mapped)
places.set(mapped.verseId, current)
}
return places
}
async function loadBrowserRoutes(): Promise<BibleRoute[]> {
browserRoutesCache ??= loadLibreBibleDevRoutes()
return browserRoutesCache
}
async function loadBrowserAudioPlaylists(): Promise<Map<string, ImportedAudioChapterPlaylist>> {
browserAudioPlaylistCache ??= loadLibreBibleDevAudioPlaylists()
return browserAudioPlaylistCache
}
async function loadLibreBibleDevAudioPlaylists(): Promise<Map<string, ImportedAudioChapterPlaylist>> {
const importedEntries = await fetchJsonl<ImportedAudioChapterPlaylist>(
`${libreBibleDevRoot}/kjv-audio-eliranwong/chapter-playlists.jsonl`,
)
return new Map(importedEntries.map((entry) => [`${entry.book_id}.${entry.chapter}`, entry]))
}
function mapAudioSegment(
playlist: ImportedAudioChapterPlaylist,
index: number,
licenseName: string,
attribution: string,
): AudioBibleSegment {
const id = playlist.segment_ids[index]
const verseId = id?.split(':').at(-1) ?? ''
const verseNumber = Number(verseId.split('.').at(-1) ?? index + 1)
return {
id,
resourceId: playlist.resource_id,
translationId: playlist.translation_id,
verseId,
reference: `${playlist.book} ${playlist.chapter}:${verseNumber}`,
audioZipUrl: playlist.audio_zip_url,
audioZipFile: playlist.audio_zip_file,
audioEntryPath: playlist.audio_entry_paths[index],
attribution,
licenseName,
}
}
async function loadAudioZip(url: string): Promise<JSZip> {
if (!browserAudioZipCache.has(url)) {
browserAudioZipCache.set(
url,
fetch(url)
.then(async (response) => {
if (!response.ok) {
throw new Error(`Unable to load audio package: ${response.status} ${response.statusText}`)
}
return response.arrayBuffer()
})
.then((data) => JSZip.loadAsync(data)),
)
}
return browserAudioZipCache.get(url)!
}
async function loadLibreBibleDevRoutes(): Promise<BibleRoute[]> {
const importedEntries = await fetchJsonl<ImportedBibleRoute>(
`${libreBibleDevRoot}/bible-routes-ubs-marble/routes.jsonl`,
)
return importedEntries.map((entry) => ({
id: entry.id,
routeNumber: entry.route_number,
title: entry.title,
sectionNumber: entry.section_number,
sectionTitle: entry.section_title,
mapNumber: entry.map_number,
geojsonFile: entry.geojson_file,
bounds: entry.bounds
? {
minLon: entry.bounds.min_lon,
minLat: entry.bounds.min_lat,
maxLon: entry.bounds.max_lon,
maxLat: entry.bounds.max_lat,
}
: null,
}))
}
async function relatedRoutesForPlaces(places: BiblePlace[]): Promise<BibleRoute[]> {
if (places.length === 0) return []
const routes = await loadBrowserRoutes()
const placeTerms = new Set(
places.flatMap((place) => [place.placeName, place.modernLocationName])
.map(normalizeTerm)
.filter((term) => term.length >= 4),
)
const matches = routes
.map((route) => {
const haystack = normalizeTerm(`${route.title} ${route.sectionTitle}`)
const score = [...placeTerms].filter((term) => haystack.includes(term)).length
return { route, score }
})
.filter((entry) => entry.score > 0)
.sort((left, right) => right.score - left.score || left.route.routeNumber - right.route.routeNumber)
return matches.slice(0, 6).map((entry) => entry.route)
}
function uniquePlaces(places: BiblePlace[]) {
const unique = new Map<string, BiblePlace>()
for (const place of places) {
if (place.lon == null || place.lat == null) continue
unique.set(place.placeId, place)
}
return [...unique.values()].sort(
(left, right) =>
(right.confidence ?? 0) - (left.confidence ?? 0) ||
left.placeName.localeCompare(right.placeName),
)
}
async function loadLibreBibleDevDictionary(): Promise<Map<string, DictionaryEntry>> {
const importedEntries = await fetchJsonl<ImportedDictionaryEntry>(
`${libreBibleDevRoot}/easton-smith-dictionaries-neuu/entries.jsonl`,
+70
View File
@@ -73,6 +73,76 @@ export type DictionaryEntry = {
sources: string[]
}
export type BiblePlace = {
id: string
verseId: string
readable: string
placeId: string
placeName: string
placeTypes: string[]
modernLocationId: string
modernLocationName: string
lon: number | null
lat: number | null
confidence: number | null
confidenceLabel: string
}
export type BibleRoute = {
id: string
routeNumber: number
title: string
sectionNumber: string
sectionTitle: string
mapNumber: string
geojsonFile: string
bounds: {
minLon: number
minLat: number
maxLon: number
maxLat: number
} | null
}
export type MapContext = {
selectedPlaces: BiblePlace[]
pagePlaces: BiblePlace[]
relatedRoutes: BibleRoute[]
}
export type AudioBibleSegment = {
id: string
resourceId: string
translationId: string
verseId: string
reference: string
audioZipUrl: string
audioZipFile: string
audioEntryPath: string
attribution: string
licenseName: string
}
export type AudioChapterPlaylist = {
id: string
resourceId: string
translationId: string
book: string
chapter: number
reference: string
segments: AudioBibleSegment[]
}
export type AudioBibleContext = {
resourceId: string
title: string
shortTitle: string
licenseName: string
attribution: string
selectedSegment: AudioBibleSegment | null
chapterPlaylist: AudioChapterPlaylist | null
}
export type LibraryState = {
verses: Verse[]
tags: Tag[]