diff --git a/src/App.css b/src/App.css
index 433fb2b..fbd5d9b 100644
--- a/src/App.css
+++ b/src/App.css
@@ -396,18 +396,18 @@
.app-main {
display: grid;
- grid-template-columns: 56px 1fr 300px;
+ grid-template-columns: var(--palette-width) 1fr 300px;
flex: 1;
min-height: 0;
overflow: hidden;
}
.app-shell[data-layout='desktop'] .app-main {
- grid-template-columns: 56px 1fr 300px;
+ grid-template-columns: var(--palette-width) 1fr 300px;
}
.app-shell[data-layout='compact'] .app-main {
- grid-template-columns: 56px 1fr;
+ grid-template-columns: var(--palette-width) 1fr;
}
.app-shell[data-layout='touch'] .app-main {
@@ -2601,3 +2601,23 @@ a.statusbar-legal-link {
min-width: 0;
flex: 1;
}
+
+/* The palette keeps a fixed width so the canvas never sits underneath it.
+ Longer translated labels are truncated with an ellipsis (full text is in the tooltip). */
+.tool-palette {
+ box-sizing: border-box;
+ flex: 0 0 var(--palette-width);
+ width: var(--palette-width);
+ min-width: 0;
+ max-width: var(--palette-width);
+ overflow: hidden;
+}
+
+.tool-palette .tool-label {
+ display: block;
+ max-width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-align: center;
+}
diff --git a/src/components/AnnotationsPanel.tsx b/src/components/AnnotationsPanel.tsx
index ed60b37..69184d7 100644
--- a/src/components/AnnotationsPanel.tsx
+++ b/src/components/AnnotationsPanel.tsx
@@ -11,6 +11,7 @@ import Tooltip from './Tooltip'
import BottomSheet from './BottomSheet'
import SlideDrawer from './SlideDrawer'
import AnnotationAdvertisement from './AnnotationAdvertisement'
+import { useT } from '../i18n'
function AnnotationItem({
annotation,
@@ -23,6 +24,7 @@ function AnnotationItem({
selected: boolean
layoutMode: LayoutMode
}) {
+ const t = useT()
const setSelectedId = useAppStore((s) => s.setSelectedId)
const updateAnnotation = useAppStore((s) => s.updateAnnotation)
const removeAnnotation = useAppStore((s) => s.removeAnnotation)
@@ -60,7 +62,7 @@ function AnnotationItem({
{
e.stopPropagation()
removeAnnotation(annotation.id)
@@ -87,7 +89,7 @@ function AnnotationItem({
{layoutMode === 'touch' ? (
markerBadge
) : (
-
+
{markerBadge}
)}
@@ -96,7 +98,7 @@ function AnnotationItem({
)
@@ -138,7 +141,7 @@ function MarkerDisplayToggle() {
aria-pressed={markerDisplayMode === 'comments'}
onClick={() => setMarkerDisplayMode('comments')}
>
- Comments
+ {t('notes.comments')}
)
@@ -146,13 +149,13 @@ function MarkerDisplayToggle() {
-
+
{labelsButton}
{commentsButton}
@@ -166,6 +169,7 @@ function AnnotationsContent({
}: {
layoutMode: LayoutMode
}) {
+ const t = useT()
const annotations = useAppStore((s) => s.annotations)
const selectedId = useAppStore((s) => s.selectedId)
@@ -174,14 +178,14 @@ function AnnotationsContent({
-
Annotations
+ {t('notes.heading')}
{annotations.length === 0 ? (
- No annotations yet. Use the marker tool to add comments to the map.
+ {t('notes.empty')}
) : (
@@ -207,6 +211,7 @@ export default function AnnotationsPanel({
variant: 'sidebar' | 'drawer' | 'sheet'
layoutMode: LayoutMode
}) {
+ const t = useT()
const annotations = useAppStore((s) => s.annotations)
const annotationsOpen = useAppStore((s) => s.annotationsOpen)
const toggleAnnotations = useAppStore((s) => s.toggleAnnotations)
@@ -229,18 +234,18 @@ export default function AnnotationsPanel({
- Notes
+ {t('notes.toggle')}
{annotations.length > 0 && (
{annotations.length}
)}
@@ -259,7 +264,7 @@ export default function AnnotationsPanel({
return (
diff --git a/src/components/AppErrorBoundary.tsx b/src/components/AppErrorBoundary.tsx
index 2b5d96e..6366e37 100644
--- a/src/components/AppErrorBoundary.tsx
+++ b/src/components/AppErrorBoundary.tsx
@@ -1,4 +1,5 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
+import { t } from '../i18n'
interface Props {
children: ReactNode
@@ -29,17 +30,14 @@ export default class AppErrorBoundary extends Component {
return (
-
Something went wrong
-
- O-Analysis ran into an unexpected error. Reloading the page usually
- fixes this.
-
+
{t('error.title')}
+
{t('error.body')}
- Reload page
+ {t('error.reload')}
diff --git a/src/components/BottomSheet.tsx b/src/components/BottomSheet.tsx
index 0cff55d..b6ae8db 100644
--- a/src/components/BottomSheet.tsx
+++ b/src/components/BottomSheet.tsx
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef } from 'react'
import { X } from 'lucide-react'
import { useEscapeKey } from '../hooks/useEscapeKey'
+import { useT } from '../i18n'
export default function BottomSheet({
title,
@@ -13,6 +14,7 @@ export default function BottomSheet({
children: React.ReactNode
className?: string
}) {
+ const t = useT()
const titleId = useId()
const sheetRef = useRef(null)
@@ -52,7 +54,7 @@ export default function BottomSheet({
diff --git a/src/components/BottomToolBar.tsx b/src/components/BottomToolBar.tsx
index 3caa532..988a68c 100644
--- a/src/components/BottomToolBar.tsx
+++ b/src/components/BottomToolBar.tsx
@@ -5,7 +5,9 @@ import { useAppStore } from '../store'
import BottomSheet from './BottomSheet'
import StrokeSettings from './StrokeSettings'
import CanvasRotationControl from './CanvasRotationControl'
+import { useT } from '../i18n'
import TextSizeSlider from './TextSizeSlider'
+import LanguageSelect from './LanguageSelect'
export default function BottomToolBar() {
const activeTool = useAppStore((s) => s.activeTool)
@@ -16,24 +18,25 @@ export default function BottomToolBar() {
const toggleAnnotations = useAppStore((s) => s.toggleAnnotations)
const [settingsOpen, setSettingsOpen] = useState(false)
+ const t = useT()
return (
<>
-
+
{TOOLS.map(({ id, label, shortLabel, icon: Icon }) => (
setActiveTool(id)}
>
- {shortLabel}
+ {t(shortLabel)}
))}
@@ -41,22 +44,22 @@ export default function BottomToolBar() {
setSettingsOpen(true)}
>
- Style
+ {t('toolbar.style')}
- Notes
+ {t('toolbar.notes')}
{annotations.length > 0 && (
{annotations.length}
)}
@@ -64,9 +67,10 @@ export default function BottomToolBar() {
{settingsOpen && (
- setSettingsOpen(false)}>
+ setSettingsOpen(false)}>
-
+
+
)}
diff --git a/src/components/CanvasArea.tsx b/src/components/CanvasArea.tsx
index f323512..28a01ba 100644
--- a/src/components/CanvasArea.tsx
+++ b/src/components/CanvasArea.tsx
@@ -23,6 +23,7 @@ import {
} from '../utils/viewport'
import { getMapPointer } from '../utils/mapPointer'
import type { Point, Viewport } from '../types'
+import { useT } from '../i18n'
const ZOOM_FACTOR = 1.06
const WHEEL_COMMIT_MS = 120
@@ -38,6 +39,7 @@ export default function CanvasArea({
}: {
layoutMode: LayoutMode
}) {
+ const t = useT()
const activeTool = useAppStore((s) => s.activeTool)
const mapImage = useAppStore((s) => s.mapImage)
const setViewport = useAppStore((s) => s.setViewport)
@@ -618,11 +620,9 @@ export default function CanvasArea({
-
No map loaded
+
{t('canvas.noMap')}
- {isTouch
- ? 'Import a map image to get started.'
- : 'Import a map image to get started, or drop a file here.'}
+ {isTouch ? t('canvas.importHintTouch') : t('canvas.importHintDrop')}
mapInputRef.current?.click()}
>
- Import Map
+ {t('canvas.importMap')}
void handleLoadSample()}
>
- {loadingSample ? 'Loading sample…' : 'Open Sample Project'}
+ {loadingSample ? t('canvas.loadingSample') : t('canvas.openSample')}
diff --git a/src/components/CanvasRotationControl.tsx b/src/components/CanvasRotationControl.tsx
index ad0e7dc..ae2ee4b 100644
--- a/src/components/CanvasRotationControl.tsx
+++ b/src/components/CanvasRotationControl.tsx
@@ -2,6 +2,7 @@ import { RotateCcw } from 'lucide-react'
import { useAppStore } from '../store'
import { stageRef } from '../stageRef'
import { clampRotation, rotateViewportKeepingCenter } from '../utils/viewport'
+import { useT } from '../i18n'
const MIN_ROTATION = -360
const MAX_ROTATION = 360
@@ -11,6 +12,7 @@ export default function CanvasRotationControl({
}: {
compact?: boolean
}) {
+ const t = useT()
const mapImage = useAppStore((s) => s.mapImage)
const rotation = useAppStore((s) => s.viewport.rotation)
const setViewport = useAppStore((s) => s.setViewport)
@@ -48,10 +50,10 @@ export default function CanvasRotationControl({
- {compact ? 'Rotation' : 'Rotate'}
+ {compact ? t('rotation.label') : t('rotation.rotate')}
applyRotation(Number(e.target.value))}
/>
{rotation}°
applyRotation(0)}
>
diff --git a/src/components/ColorPickerModal.tsx b/src/components/ColorPickerModal.tsx
index 9da9f93..e25bdf9 100644
--- a/src/components/ColorPickerModal.tsx
+++ b/src/components/ColorPickerModal.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { X } from 'lucide-react'
+import { useT } from '../i18n'
function clampByte(n: number): number {
return Math.min(255, Math.max(0, Math.round(n)))
@@ -40,6 +41,7 @@ export default function ColorPickerModal({
onApply: (color: string) => void
onClose: () => void
}) {
+ const t = useT()
const parsed = hexToRgb(initialColor) ?? { r: 8, g: 6, b: 13 }
const [r, setR] = useState(parsed.r)
const [g, setG] = useState(parsed.g)
@@ -91,11 +93,11 @@ export default function ColorPickerModal({
onClick={(e) => e.stopPropagation()}
>
-
Custom color
+ {t('color.custom')}
@@ -126,7 +128,7 @@ export default function ColorPickerModal({
value={value}
style={{ accentColor: accent }}
onChange={(e) => updateChannel(key, Number(e.target.value))}
- aria-label={`${label} channel`}
+ aria-label={t('color.channel', { channel: label })}
/>
updateChannel(key, Number(e.target.value))}
- aria-label={`${label} value`}
+ aria-label={t('color.channelValue', { channel: label })}
/>
))}
- Hex
+ {t('color.hex')}
applyHexInput(e.target.value)}
- aria-label="Hex color"
+ aria-label={t('color.hexAria')}
/>
@@ -156,14 +158,14 @@ export default function ColorPickerModal({
- Cancel
+ {t('common.cancel')}
- Apply
+ {t('common.apply')}
diff --git a/src/components/EditGpxModal.tsx b/src/components/EditGpxModal.tsx
index 9ec5188..a51ae52 100644
--- a/src/components/EditGpxModal.tsx
+++ b/src/components/EditGpxModal.tsx
@@ -18,6 +18,7 @@ import type { LayoutMode } from '../hooks/useLayoutMode'
import Tooltip from './Tooltip'
import GpxCropPanel from './GpxCropPanel'
import DualRangeSlider from './DualRangeSlider'
+import { useT } from '../i18n'
const PACE_SLIDER_MIN = 2
const PACE_SLIDER_MAX = 20
@@ -103,6 +104,7 @@ export default function EditGpxModal({
onClose: () => void
onUploadNew: () => void
}) {
+ const t = useT()
const tracks = useAppStore((s) => s.tracks)
const mapImage = useAppStore((s) => s.mapImage)
const updateTrack = useAppStore((s) => s.updateTrack)
@@ -129,7 +131,7 @@ export default function EditGpxModal({
setActiveTool('gpx')
onClose()
if (isTouch) {
- setToastMessage('Tap the track to add pins. Long-press a pin to delete.')
+ setToastMessage(t('gpx.toastTapHint'))
}
}
@@ -160,11 +162,11 @@ export default function EditGpxModal({
onClick={(e) => e.stopPropagation()}
>
-
GPX tracks
+
{t('gpx.title')}
@@ -330,7 +332,7 @@ export default function EditGpxModal({
onClick={() => toggleCrop(track.id)}
>
- Crop
+ {t('gpx.crop')}
- Adjust on map
+ {t('gpx.adjustMap')}
handleRemove(track.id)}
>
- Remove
+ {t('gpx.remove')}
>
) : (
<>
-
+
toggleCrop(track.id)}
>
-
+
-
+
handleRemove(track.id)}
>
@@ -393,7 +395,7 @@ export default function EditGpxModal({
setLineStyle('solid')}
>
- Solid
+ {t('gpx.solid')}
{timing && (
setLineStyle('pace')}
>
- Pace
+ {t('gpx.pace')}
)}
{hasHr && (
@@ -422,7 +424,7 @@ export default function EditGpxModal({
aria-pressed={hrMode}
onClick={() => setLineStyle('hr')}
>
- HR
+ {t('gpx.hr')}
)}
@@ -434,19 +436,19 @@ export default function EditGpxModal({
type="button"
className={`swatch${track.color === color ? ' active' : ''}`}
style={{ background: color }}
- aria-label={`Track colour ${color}`}
+ aria-label={t('gpx.trackColour', { color })}
onClick={() => updateTrack(track.id, { color })}
/>
))}
- Width
+ {t('gpx.width')}
updateTrack(
track.id,
@@ -459,13 +461,13 @@ export default function EditGpxModal({
- Opac
+ {t('gpx.opacityShort')}
updateTrack(
track.id,
@@ -481,7 +483,7 @@ export default function EditGpxModal({
@@ -523,7 +525,7 @@ export default function EditGpxModal({
@@ -578,7 +580,7 @@ export default function EditGpxModal({
)
})}
{tracks.length === 0 && (
- No GPX tracks loaded.
+ {t('gpx.empty')}
)}
@@ -592,14 +594,14 @@ export default function EditGpxModal({
}}
>
- Upload
+ {t('gpx.upload')}
- Confirm
+ {t('gpx.confirm')}
diff --git a/src/components/ExportModal.tsx b/src/components/ExportModal.tsx
index a1292dc..0a475cb 100644
--- a/src/components/ExportModal.tsx
+++ b/src/components/ExportModal.tsx
@@ -14,6 +14,7 @@ import {
type ExportPreviewImages,
} from '../utils/export'
import type { LayoutMode } from '../hooks/useLayoutMode'
+import { t, useT } from '../i18n'
import { useEscapeKey } from '../hooks/useEscapeKey'
const PREVIEW_ZOOM_MIN = 1
@@ -23,7 +24,7 @@ const PREVIEW_ZOOM_STEP = 0.25
function exportFailureMessage(error: unknown): string {
return error instanceof Error
? error.message
- : 'Export failed — please try again'
+ : t('export.failed')
}
export default function ExportModal({
@@ -33,6 +34,7 @@ export default function ExportModal({
layoutMode: LayoutMode
onClose: () => void
}) {
+ const t = useT()
const annotations = useAppStore((s) => s.annotations)
const markerDisplayMode = useAppStore((s) => s.markerDisplayMode)
@@ -228,7 +230,7 @@ export default function ExportModal({
const zoomLabel =
previewZoom <= 1.01
- ? 'Fit'
+ ? t('export.fit')
: useFullRes
? `${Math.round(Math.min(100, displayScale * 100))}%`
: `${Math.round(previewZoom * 100)}%`
@@ -243,11 +245,11 @@ export default function ExportModal({
onClick={(e) => e.stopPropagation()}
>
-
Export
+
{t('export.title')}
@@ -260,12 +262,12 @@ export default function ExportModal({
className={`export-preview-wrap${isPanning ? ' is-panning' : ''}${previewZoom > 1 ? ' is-zoomed' : ''}`}
>
{previewLoading && !previews && (
- Generating preview…
+ {t('export.generating')}
)}
{previews && (
<>
{!previewReady && (
- Loading preview…
+ {t('export.loadingPreview')}
)}
setZoom((current) => current - PREVIEW_ZOOM_STEP)
@@ -320,7 +322,7 @@ export default function ExportModal({
= previewZoomMax - 0.01}
onClick={() =>
setZoom((current) => current + PREVIEW_ZOOM_STEP)
@@ -331,7 +333,7 @@ export default function ExportModal({
- Scroll to zoom · drag to pan
+ {t('export.hint')}
>
)}
@@ -357,23 +359,23 @@ export default function ExportModal({
checked={includeSidebar}
onChange={(e) => setIncludeSidebar(e.target.checked)}
/>
- Include annotation sidebar
+ {t('export.includeSidebar')}
{markerDisplayMode === 'comments'
- ? 'Comments are shown on the map — turn off the sidebar to avoid duplicating them.'
- : 'The sidebar lists each marker letter with its comment.'}
+ ? t('export.sidebarWithComments')
+ : t('export.sidebarWithLabels')}
- Text size
+ {t('export.textSize')}
setAnnotationTextScalePct(Number(e.target.value))
}
@@ -391,14 +393,13 @@ export default function ExportModal({
)}
{!exportError && previews && previews.scale < 1 && (
- This map is larger than this device can export at full size, so the
- PNG will be {Math.round(previews.scale * 100)}% of native resolution.
+ {t('export.scaleNotice', { percent: Math.round(previews.scale * 100) })}
)}
- Cancel
+ {t('common.cancel')}
void handleExport()}
>
- {exporting ? 'Exporting…' : 'Download PNG'}
+ {exporting ? t('export.exporting') : t('export.download')}
diff --git a/src/components/GpxCropPanel.tsx b/src/components/GpxCropPanel.tsx
index d3d69e9..8695630 100644
--- a/src/components/GpxCropPanel.tsx
+++ b/src/components/GpxCropPanel.tsx
@@ -7,6 +7,7 @@ import {
formatPace,
vertexCount,
} from '../utils/gpxMetrics'
+import { formatNumber, t, useT } from '../i18n'
const CHART_HEIGHT = 160
const PAD = { top: 14, right: 14, bottom: 30, left: 46 }
@@ -18,8 +19,8 @@ interface GpxCropPanelProps {
}
function formatDistanceKm(km: number): string {
- if (km < 10) return `${km.toFixed(2)} km`
- return `${km.toFixed(1)} km`
+ const digits = km < 10 ? 2 : 1
+ return t('gpx.km', { value: formatNumber(km, digits) })
}
export default function GpxCropPanel({
@@ -27,6 +28,7 @@ export default function GpxCropPanel({
onApply,
onCancel,
}: GpxCropPanelProps) {
+ const t = useT()
const n = vertexCount(track)
const [startIndex, setStartIndex] = useState(0)
const [endIndex, setEndIndex] = useState(n - 1)
@@ -155,10 +157,10 @@ export default function GpxCropPanel({
return (
-
Crop track
+
{t('gpx.cropTitle')}
{!hasTiming && (
- No timing data in this GPX — crop by distance
+ {t('gpx.noTiming')}
)}
@@ -326,12 +328,12 @@ export default function GpxCropPanel({
{!isValid && (
- Selection must include at least 2 points.
+ {t('gpx.cropError')}
)}
- Cancel
+ {t('common.cancel')}
onApply(cropTrack(track, startIndex, endIndex))}
>
- Apply crop
+ {t('gpx.applyCrop')}
diff --git a/src/components/LanguageSelect.tsx b/src/components/LanguageSelect.tsx
new file mode 100644
index 0000000..9228f90
--- /dev/null
+++ b/src/components/LanguageSelect.tsx
@@ -0,0 +1,22 @@
+import { LOCALES, getLocale, setLocale, useT, type Locale } from '../i18n'
+
+/** Language picker. Switching takes effect immediately; the choice is remembered. */
+export default function LanguageSelect({ className = 'statusbar-language' }: { className?: string }) {
+ const t = useT()
+
+ return (
+ void setLocale(e.target.value as Locale)}
+ >
+ {LOCALES.map(({ code, name }) => (
+
+ {name}
+
+ ))}
+
+ )
+}
diff --git a/src/components/LegalModal.tsx b/src/components/LegalModal.tsx
index fb55b80..b11a1ad 100644
--- a/src/components/LegalModal.tsx
+++ b/src/components/LegalModal.tsx
@@ -1,6 +1,7 @@
import { X } from 'lucide-react'
import { LEGAL_DOCUMENTS, type LegalDocumentId } from '../content/legal'
import Portal from './Portal'
+import { useT } from '../i18n'
import { useEscapeKey } from '../hooks/useEscapeKey'
export default function LegalModal({
@@ -10,6 +11,7 @@ export default function LegalModal({
documentId: LegalDocumentId
onClose: () => void
}) {
+ const t = useT()
const doc = LEGAL_DOCUMENTS[documentId]
useEscapeKey(onClose)
@@ -29,14 +31,15 @@ export default function LegalModal({
-
Last updated: {doc.updated}
+
{t('legal.lastUpdated', { date: doc.updated })}
+
{t('legal.binding')}
{doc.sections.map((section) => (
{section.heading}
diff --git a/src/components/ProjectLibraryModal.tsx b/src/components/ProjectLibraryModal.tsx
index e978f80..cef2ec9 100644
--- a/src/components/ProjectLibraryModal.tsx
+++ b/src/components/ProjectLibraryModal.tsx
@@ -5,6 +5,7 @@ import { useEscapeKey } from '../hooks/useEscapeKey'
import type { LayoutMode } from '../hooks/useLayoutMode'
import {
listProjectMetas,
+ projectDisplayName,
readThumbnail,
type ProjectMeta,
} from '../persistence/library'
@@ -16,6 +17,7 @@ import {
renameLibraryProject,
} from '../projectLibrary'
import { useSaveState } from '../saveStatus'
+import { getLocale, useT } from '../i18n'
export default function ProjectLibraryModal({
layoutMode,
@@ -24,6 +26,7 @@ export default function ProjectLibraryModal({
layoutMode: LayoutMode
onClose: () => void
}) {
+ const t = useT()
const currentId = useAppStore((s) => s.projectId)
const saveState = useSaveState()
const [metas, setMetas] = useState(null)
@@ -80,7 +83,7 @@ export default function ProjectLibraryModal({
try {
await action()
} catch (err) {
- setError(err instanceof Error ? err.message : 'Something went wrong')
+ setError(err instanceof Error ? err.message : t('common.errorGeneric'))
} finally {
setBusy(false)
}
@@ -131,11 +134,11 @@ export default function ProjectLibraryModal({
onClick={(e) => e.stopPropagation()}
>
-
Projects
+
{t('library.title')}
@@ -149,11 +152,11 @@ export default function ProjectLibraryModal({
)}
- {metas === null && Loading…
}
+ {metas === null && {t('common.loading')}
}
{metas?.length === 0 && (
- No saved projects yet. Import a map to start one.
+ {t('library.empty')}
)}
@@ -181,7 +184,7 @@ export default function ProjectLibraryModal({
autoFocus
value={draftName}
maxLength={80}
- aria-label="Project name"
+ aria-label={t('top.projectName')}
onChange={(e) => setDraftName(e.target.value)}
onBlur={() => void commitRename(meta)}
onKeyDown={(e) => {
@@ -206,16 +209,18 @@ export default function ProjectLibraryModal({
- {meta.name}
+ {projectDisplayName(meta.name)}
{isCurrent && (
- Open
+ {t('library.open')}
)}
- {meta.mapName ?? 'No map yet'} · Updated{' '}
- {new Date(meta.updatedAt).toLocaleString([], {
- dateStyle: 'medium',
- timeStyle: 'short',
+ {t('library.updated', {
+ map: meta.mapName ?? t('library.noMap'),
+ date: new Date(meta.updatedAt).toLocaleString(getLocale(), {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }),
})}
@@ -230,7 +235,7 @@ export default function ProjectLibraryModal({
className="button"
onClick={() => setConfirmDeleteId(null)}
>
- Cancel
+ {t('common.cancel')}
remove(meta)}
>
- Delete
+ {t('common.delete')}
>
) : (
@@ -246,8 +251,8 @@ export default function ProjectLibraryModal({
{
setEditingId(meta.id)
@@ -259,8 +264,8 @@ export default function ProjectLibraryModal({
remove(meta)}
>
@@ -278,7 +283,7 @@ export default function ProjectLibraryModal({
- Close
+ {t('common.close')}
void startNew()}
>
- New project
+ {t('action.newProject')}
diff --git a/src/components/PwaUpdateBanner.tsx b/src/components/PwaUpdateBanner.tsx
index 37e90bb..edee56e 100644
--- a/src/components/PwaUpdateBanner.tsx
+++ b/src/components/PwaUpdateBanner.tsx
@@ -1,7 +1,9 @@
import { RefreshCw, X } from 'lucide-react'
import { usePwaUpdate } from '../hooks/usePwaUpdate'
+import { useT } from '../i18n'
export default function PwaUpdateBanner() {
+ const t = useT()
const { updateAvailable, applyUpdate, dismissUpdate } = usePwaUpdate()
if (!updateAvailable) return null
@@ -9,18 +11,18 @@ export default function PwaUpdateBanner() {
return (
-
A new version of O-Analysis is available.
+
{t('pwa.updateAvailable')}
- Update now
+ {t('pwa.updateNow')}
diff --git a/src/components/SlideDrawer.tsx b/src/components/SlideDrawer.tsx
index f57ac4e..0ffd336 100644
--- a/src/components/SlideDrawer.tsx
+++ b/src/components/SlideDrawer.tsx
@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { X } from 'lucide-react'
+import { useT } from '../i18n'
export default function SlideDrawer({
title,
@@ -12,6 +13,7 @@ export default function SlideDrawer({
onClose: () => void
children: React.ReactNode
}) {
+ const t = useT()
useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent) => {
@@ -38,7 +40,7 @@ export default function SlideDrawer({
diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx
index e0053d7..d7bef7b 100644
--- a/src/components/StatusBar.tsx
+++ b/src/components/StatusBar.tsx
@@ -8,6 +8,8 @@ import type { SaveStatus } from '../saveStatus'
import { stageRef } from '../stageRef'
import { useSaveState } from '../saveStatus'
import TextSizeSlider from './TextSizeSlider'
+import LanguageSelect from './LanguageSelect'
+import { getLocale, useT } from '../i18n'
import CanvasRotationControl from './CanvasRotationControl'
import LegalModal from './LegalModal'
@@ -20,16 +22,17 @@ function SaveIndicator({
}: {
state: { status: SaveStatus; savedAt: number | null }
}) {
+ const t = useT()
if (state.status === 'idle') return null
const label =
state.status === 'error'
- ? 'Autosave failed'
+ ? t('save.failed')
: state.status === 'saved' && state.savedAt
- ? `Saved ${new Date(state.savedAt).toLocaleTimeString([], {
+ ? t('save.saved', { time: new Date(state.savedAt).toLocaleTimeString(getLocale(), {
hour: '2-digit',
minute: '2-digit',
- })}`
- : 'Saving…'
+ }) })
+ : t('save.saving')
return (
s.activeTool)
const scale = useAppStore((s) => s.viewport.scale)
const setViewport = useAppStore((s) => s.setViewport)
@@ -90,8 +94,8 @@ export default function StatusBar({
className="statusbar-zoom"
disabled={!hasMap}
onClick={requestFitToView}
- title="Fit the map to the screen"
- aria-label={`Zoom ${Math.round(scale * 100)}%. Fit map to screen`}
+ title={t('status.fitHint')}
+ aria-label={t('status.fitAria', { zoom: Math.round(scale * 100) })}
>
{Math.round(scale * 100)}%
@@ -102,7 +106,7 @@ export default function StatusBar({
zoomBy(-1)}
>
@@ -110,33 +114,34 @@ export default function StatusBar({
zoomBy(1)}
>
)}
- {showTextSize && }
+ {showTextSize && }
{showCoords && (
{pointer
- ? `X: ${Math.round(pointer.x)} Y: ${Math.round(pointer.y)}`
- : 'X: — Y: —'}
+ ? t('status.coords', { x: Math.round(pointer.x), y: Math.round(pointer.y) })
+ : t('status.noCoords')}
)}
{showToolName && (
- Tool: {getToolLabel(activeTool)}
+ {t('status.tool', { tool: getToolLabel(activeTool) })}
)}
+ {showLegalLinks && }
{showLegalLinks && (
-
+
setLegalDoc('privacy')}
>
- Privacy
+ {t('status.privacy')}
·
@@ -146,7 +151,7 @@ export default function StatusBar({
className="statusbar-legal-link"
onClick={() => setLegalDoc('terms')}
>
- Terms
+ {t('status.terms')}
{showCredit && (
<>
@@ -159,7 +164,7 @@ export default function StatusBar({
target="_blank"
rel="noopener noreferrer"
>
- by Anthony Riley ❤️
+ {t('status.credit')}
>
)}
diff --git a/src/components/StrokeSettings.tsx b/src/components/StrokeSettings.tsx
index ee05aa0..c359e71 100644
--- a/src/components/StrokeSettings.tsx
+++ b/src/components/StrokeSettings.tsx
@@ -4,6 +4,7 @@ import { useAppStore } from '../store'
import ColorPickerModal from './ColorPickerModal'
import Tooltip from './Tooltip'
import type { LayoutMode } from '../hooks/useLayoutMode'
+import { useT } from '../i18n'
const DEFAULT_CUSTOM_COLOR = '#08060d'
@@ -12,6 +13,7 @@ export default function StrokeSettings({
}: {
layoutMode: LayoutMode
}) {
+ const t = useT()
const strokeWidth = useAppStore((s) => s.strokeWidth)
const setStrokeWidth = useAppStore((s) => s.setStrokeWidth)
const strokeColor = useAppStore((s) => s.strokeColor)
@@ -37,7 +39,7 @@ export default function StrokeSettings({
setPickerOpen(true)}
>
@@ -61,7 +63,7 @@ export default function StrokeSettings({
- Width
+ {t('stroke.width')}
- Opacity
+ {t('stroke.opacity')}
{Math.round(strokeOpacity * 100)}%
-
Color
+
{t('stroke.color')}
{SWATCHES.map((color) => (
setStrokeColor(color)}
/>
))}
@@ -110,10 +112,10 @@ export default function StrokeSettings({
return (
<>
-
+
- Width
+ {t('stroke.width')}
{strokeWidth}px
-
+
- Opacity
+ {t('stroke.opacity')}
-
Color
+
{t('stroke.color')}
{SWATCHES.map((color) => (
-
+
setStrokeColor(color)}
/>
))}
-
+
{customSwatch}
diff --git a/src/components/ToastBanner.tsx b/src/components/ToastBanner.tsx
index c2a8f58..78840b2 100644
--- a/src/components/ToastBanner.tsx
+++ b/src/components/ToastBanner.tsx
@@ -1,8 +1,10 @@
import { useEffect } from 'react'
import { X } from 'lucide-react'
import { useAppStore } from '../store'
+import { useT } from '../i18n'
export default function ToastBanner() {
+ const t = useT()
const message = useAppStore((s) => s.toastMessage)
const setToastMessage = useAppStore((s) => s.setToastMessage)
@@ -20,7 +22,7 @@ export default function ToastBanner() {
setToastMessage(null)}
>
diff --git a/src/components/ToolPalette.tsx b/src/components/ToolPalette.tsx
index c64c510..6b50e87 100644
--- a/src/components/ToolPalette.tsx
+++ b/src/components/ToolPalette.tsx
@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { TOOLS, getToolByHotkey, getToolTip } from '../config/tools'
+import { useT } from '../i18n'
import type { LayoutMode } from '../hooks/useLayoutMode'
import { useAppStore } from '../store'
import StrokeSettings from './StrokeSettings'
@@ -13,6 +14,7 @@ export default function ToolPalette({
const activeTool = useAppStore((s) => s.activeTool)
const setActiveTool = useAppStore((s) => s.setActiveTool)
const hasTracks = useAppStore((s) => s.tracks.length > 0)
+ const t = useT()
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
@@ -43,19 +45,19 @@ export default function ToolPalette({
return (
-
+
{TOOLS.map(({ id, label, hotkey, icon: Icon }) => {
const tip =
id === 'gpx' && !hasTracks
- ? 'Import a GPX file first'
- : `${getToolTip(id, layoutMode)} (${hotkey})`
+ ? t('tool.gpxNeedsFile')
+ : t('tool.hotkeyLabel', { tip: getToolTip(id, layoutMode), key: hotkey })
return (
s.mapImage)
const hasTracks = useAppStore((s) => s.tracks.length > 0)
const hasPaths = useAppStore((s) => s.paths.length > 0)
@@ -91,7 +94,7 @@ export default function TopBar({
setImportError(null)}
>
@@ -114,10 +117,10 @@ export default function TopBar({
setProjectName(e.target.value)}
onBlur={(e) => renameCurrentProject(e.target.value)}
/>
@@ -161,24 +164,24 @@ export default function TopBar({
}}
/>
-
-
+
+
-
+
@@ -191,8 +194,8 @@ export default function TopBar({
- {mapImage ? 'Edit Image' : 'Import Image'}
+ {mapImage ? t('action.editImage') : t('action.importImage')}
- {hasTracks ? 'Edit GPX' : 'Import GPX'}
+ {hasTracks ? t('action.editGpx') : t('action.importGpx')}
-
+
- New
+ {t('top.new')}
-
+
setLibraryOpen(true)}
>
- Projects
+ {t('top.projects')}
-
+
projectInputRef.current?.click()}
>
- Open
+ {t('top.open')}
- Save
+ {t('top.save')}
setExportModalOpen(true)}
>
- Export
+ {t('action.export')}
{updateAvailable && (
-
+
- Update App
+ {t('top.updateApp')}
)}
{canInstall && (
-
+
void install()}
>
- Install App
+ {t('top.installApp')}
)}
diff --git a/src/components/TopBarMenu.tsx b/src/components/TopBarMenu.tsx
index 6a963ea..fdb3ae1 100644
--- a/src/components/TopBarMenu.tsx
+++ b/src/components/TopBarMenu.tsx
@@ -4,6 +4,7 @@ import type { LegalDocumentId } from '../content/legal'
import type { LayoutMode } from '../hooks/useLayoutMode'
import BottomSheet from './BottomSheet'
import LegalModal from './LegalModal'
+import { useT } from '../i18n'
import type { TopBarMenuAction } from './overflowActions'
export default function TopBarMenu({
@@ -15,6 +16,7 @@ export default function TopBarMenu({
}) {
const [open, setOpen] = useState(false)
const [legalDoc, setLegalDoc] = useState(null)
+ const t = useT()
const dropdownRef = useRef(null)
const visibleActions = actions.filter((a) => !a.hidden)
@@ -33,7 +35,7 @@ export default function TopBarMenu({
onClick={() => openLegal('privacy')}
>
- Privacy Policy
+ {t('menu.privacy')}
@@ -43,7 +45,7 @@ export default function TopBarMenu({
onClick={() => openLegal('terms')}
>
- Terms of Service
+ {t('menu.terms')}
@@ -90,21 +92,21 @@ export default function TopBarMenu({
setOpen(true)}
>
{open && (
- setOpen(false)}>
+ setOpen(false)}>
-
File
+ {t('menu.file')}
{menuItems}
-
Legal
+ {t('menu.legal')}
{legalMenuItems}
@@ -122,7 +124,7 @@ export default function TopBarMenu({
setOpen((v) => !v)}
>
diff --git a/src/components/TouchHints.tsx b/src/components/TouchHints.tsx
index 0a56881..d2e7c3d 100644
--- a/src/components/TouchHints.tsx
+++ b/src/components/TouchHints.tsx
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react'
import { X } from 'lucide-react'
import { useIsTouchLayout } from '../hooks/useLayoutMode'
+import { useT } from '../i18n'
const STORAGE_KEY = 'touch-hints-dismissed'
const AUTO_HIDE_MS = 12000
export default function TouchHints() {
+ const t = useT()
const isTouch = useIsTouchLayout()
const [visible, setVisible] = useState(false)
@@ -30,14 +32,11 @@ export default function TouchHints() {
return (
-
- Pinch to zoom, use two fingers to pan. Tap a tool, then tap the map to
- draw or place markers.
-
+
{t('touch.tips')}
diff --git a/src/components/overflowActions.tsx b/src/components/overflowActions.tsx
index d54fe34..0be4632 100644
--- a/src/components/overflowActions.tsx
+++ b/src/components/overflowActions.tsx
@@ -1,3 +1,4 @@
+import { t } from '../i18n'
import {
Download,
FilePlus,
@@ -40,7 +41,7 @@ export function buildOverflowActions(opts: {
return [
{
id: 'update',
- label: 'Update App',
+ label: t('action.updateApp'),
icon: ,
onClick: opts.onUpdate,
primary: true,
@@ -48,44 +49,44 @@ export function buildOverflowActions(opts: {
},
{
id: 'import-image',
- label: opts.mapImage ? 'Edit Image' : 'Import Image',
+ label: opts.mapImage ? t('action.editImage') : t('action.importImage'),
icon: ,
onClick: opts.onImportImage,
},
{
id: 'import-gpx',
- label: opts.hasTracks ? 'Edit GPX' : 'Import GPX',
+ label: opts.hasTracks ? t('action.editGpx') : t('action.importGpx'),
icon: ,
onClick: opts.hasTracks ? opts.onEditGpx : opts.onImportGpx,
},
{
id: 'new',
- label: 'New Project',
+ label: t('action.newProject'),
icon: ,
onClick: opts.onNew,
},
{
id: 'projects',
- label: 'Projects',
+ label: t('action.projects'),
icon: ,
onClick: opts.onProjects,
},
{
id: 'open',
- label: 'Open Project',
+ label: t('action.openProject'),
icon: ,
onClick: opts.onOpen,
},
{
id: 'save',
- label: 'Save Project',
+ label: t('action.saveProject'),
icon: ,
onClick: opts.onSave,
disabled: !opts.canSave,
},
{
id: 'export',
- label: 'Export',
+ label: t('action.export'),
icon: ,
onClick: opts.onExport,
disabled: !opts.mapImage,
@@ -93,7 +94,7 @@ export function buildOverflowActions(opts: {
},
{
id: 'install',
- label: 'Install App',
+ label: t('action.installApp'),
icon: ,
onClick: opts.onInstall,
hidden: !opts.canInstall,
diff --git a/src/config/tools.ts b/src/config/tools.ts
index 7bcf25e..4ecbf80 100644
--- a/src/config/tools.ts
+++ b/src/config/tools.ts
@@ -8,70 +8,72 @@ import {
} from 'lucide-react'
import type { LayoutMode } from '../hooks/useLayoutMode'
import type { Tool } from '../types'
+import { t, type MessageKey } from '../i18n'
export const SWATCHES = ['#e11d48', '#2563eb', '#16a34a', '#f59e0b']
export const TOOLS: {
id: Tool
- label: string
- shortLabel: string
- tip: string
- touchTip: string
+ /** Message keys; translate with t() at render time */
+ label: MessageKey
+ shortLabel: MessageKey
+ tip: MessageKey
+ touchTip: MessageKey
/** Desktop keyboard shortcut; matches toolbar order (1–6) */
hotkey: string
icon: typeof Hand
}[] = [
{
id: 'select',
- label: 'Select',
- shortLabel: 'Select',
- tip: 'Click paths or markers to select them. Press Delete to remove.',
- touchTip: 'Tap paths or markers to select them.',
+ label: 'tool.select',
+ shortLabel: 'tool.select',
+ tip: 'tool.select.tip',
+ touchTip: 'tool.select.touchTip',
hotkey: '1',
icon: MousePointer2,
},
{
id: 'pan',
- label: 'Pan',
- shortLabel: 'Pan',
- tip: 'Drag the map to move around. Hold Space or middle mouse for temporary pan.',
- touchTip: 'Drag with one finger, or use two fingers to pan in any tool.',
+ label: 'tool.pan',
+ shortLabel: 'tool.pan',
+ tip: 'tool.pan.tip',
+ touchTip: 'tool.pan.touchTip',
hotkey: '2',
icon: Hand,
},
{
id: 'line',
- label: 'Draw',
- shortLabel: 'Draw',
- tip: 'Click and drag to draw lines on the map.',
- touchTip: 'Drag your finger to draw lines on the map.',
+ label: 'tool.line',
+ shortLabel: 'tool.line',
+ tip: 'tool.line.tip',
+ touchTip: 'tool.line.touchTip',
hotkey: '3',
icon: Pencil,
},
{
id: 'marker',
- label: 'Marker',
- shortLabel: 'Marker',
- tip: 'Click to place a numbered marker with a comment.',
- touchTip: 'Tap to place a numbered marker with a comment.',
+ label: 'tool.marker',
+ shortLabel: 'tool.marker',
+ tip: 'tool.marker.tip',
+ touchTip: 'tool.marker.touchTip',
hotkey: '4',
icon: MapPin,
},
{
id: 'eraser',
- label: 'Eraser',
- shortLabel: 'Eraser',
- tip: 'Click or drag over paths and markers to remove them.',
- touchTip: 'Tap or drag over paths and markers to remove them.',
+ label: 'tool.eraser',
+ shortLabel: 'tool.eraser',
+ tip: 'tool.eraser.tip',
+ touchTip: 'tool.eraser.touchTip',
hotkey: '5',
icon: Eraser,
},
{
id: 'gpx',
- label: 'Adjust GPX',
- shortLabel: 'GPX',
- tip: 'Click the track to add pins, drag to move them, right-click a pin to delete it.',
- touchTip: 'Tap the track to add pins. Long-press a pin to delete it.',
+ label: 'tool.gpx',
+ shortLabel: 'tool.gpxShort',
+ tip: 'tool.gpx.tip',
+ touchTip: 'tool.gpx.touchTip',
hotkey: '6',
icon: Anchor,
},
@@ -86,9 +88,10 @@ export function getToolByHotkey(key: string): (typeof TOOLS)[number] | undefined
export function getToolTip(id: Tool, layoutMode: LayoutMode): string {
const tool = TOOLS.find((t) => t.id === id)
if (!tool) return ''
- return layoutMode === 'touch' ? tool.touchTip : tool.tip
+ return t(layoutMode === 'touch' ? tool.touchTip : tool.tip)
}
export function getToolLabel(id: Tool): string {
- return TOOLS.find((t) => t.id === id)?.label ?? id
+ const key = TOOLS.find((tool) => tool.id === id)?.label
+ return key ? t(key) : id
}
diff --git a/src/i18n/i18n.test.ts b/src/i18n/i18n.test.ts
new file mode 100644
index 0000000..b96905f
--- /dev/null
+++ b/src/i18n/i18n.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest'
+import en from '../locales/en.json'
+import { LOCALES, matchLocale, t } from './index'
+
+const locales = import.meta.glob>('../locales/*.json', {
+ eager: true,
+ import: 'default',
+})
+
+const placeholders = (text: string) => [...text.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort()
+
+describe('locale files', () => {
+ const englishKeys = Object.keys(en).sort()
+
+ it('has a file for every supported language except English', () => {
+ const files = Object.keys(locales).map((path) => path.replace('../locales/', '').replace('.json', ''))
+ for (const { code } of LOCALES) {
+ if (code === 'en') continue
+ expect(files, `missing locales/${code}.json`).toContain(code)
+ }
+ })
+
+ for (const [path, dict] of Object.entries(locales)) {
+ const name = path.replace('../locales/', '')
+
+ it(`${name} has exactly the English keys`, () => {
+ expect(Object.keys(dict).sort()).toEqual(englishKeys)
+ })
+
+ it(`${name} keeps every placeholder from English`, () => {
+ for (const key of englishKeys) {
+ expect(placeholders(dict[key]), key).toEqual(placeholders(en[key as keyof typeof en]))
+ }
+ })
+
+ it(`${name} has no empty translations`, () => {
+ for (const key of englishKeys) {
+ expect(dict[key].trim(), key).not.toBe('')
+ }
+ })
+ }
+})
+
+describe('matchLocale', () => {
+ it('maps browser tags to supported languages', () => {
+ expect(matchLocale('sv-SE')).toBe('sv')
+ expect(matchLocale('nb-NO')).toBe('nb')
+ expect(matchLocale('no')).toBe('nb')
+ expect(matchLocale('nn-NO')).toBe('nb')
+ expect(matchLocale('fi-FI')).toBe('fi')
+ expect(matchLocale('da')).toBe('da')
+ expect(matchLocale('es-MX')).toBe('es')
+ expect(matchLocale('fr-CA')).toBe('fr')
+ expect(matchLocale('pt-PT')).toBe('pt-PT')
+ expect(matchLocale('pt-BR')).toBe('pt-BR')
+ expect(matchLocale('pt')).toBe('pt-BR')
+ })
+
+ it('returns null for unsupported languages', () => {
+ expect(matchLocale('de-DE')).toBeNull()
+ expect(matchLocale('zh-CN')).toBeNull()
+ })
+})
+
+describe('t', () => {
+ it('interpolates named placeholders', () => {
+ expect(t('rotation.degrees', { degrees: 45 })).toBe('45 degrees')
+ })
+
+ it('falls back to the English text for an untranslated key', () => {
+ expect(t('common.close')).toBe('Close')
+ })
+})
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
new file mode 100644
index 0000000..cc9ae7f
--- /dev/null
+++ b/src/i18n/index.ts
@@ -0,0 +1,139 @@
+import { useSyncExternalStore } from 'react'
+import en from '../locales/en.json'
+
+/** Languages the app ships. English is always bundled; the rest load on demand. */
+export const LOCALES = [
+ { code: 'en', name: 'English' },
+ { code: 'sv', name: 'Svenska' },
+ { code: 'nb', name: 'Norsk bokmål' },
+ { code: 'fi', name: 'Suomi' },
+ { code: 'da', name: 'Dansk' },
+ { code: 'es', name: 'Español' },
+ { code: 'fr', name: 'Français' },
+ { code: 'pt-BR', name: 'Português (Brasil)' },
+ { code: 'pt-PT', name: 'Português (Portugal)' },
+] as const
+
+export type Locale = (typeof LOCALES)[number]['code']
+export type MessageKey = keyof typeof en
+/** Plain text, or per-plural-category text for counted messages */
+export type Message = string | { one: string; other: string }
+/** Every locale file must satisfy this, so a missing key fails the type check */
+export type Dictionary = Record
+export type MessageParams = Record
+
+const STORAGE_KEY = 'o-analysis-locale'
+
+const loaders = import.meta.glob<{ default: Dictionary }>('../locales/*.json')
+
+let currentLocale: Locale = 'en'
+let current: Dictionary = en as Dictionary
+const listeners = new Set<() => void>()
+
+function isLocale(value: string): value is Locale {
+ return LOCALES.some((l) => l.code === value)
+}
+
+/**
+ * Picks the closest supported language for a browser language tag.
+ * Norwegian nn/no map to Bokmål; any Portuguese region other than Portugal
+ * maps to Brazilian Portuguese, which is the larger audience.
+ */
+export function matchLocale(tag: string): Locale | null {
+ const lower = tag.toLowerCase()
+ if (lower === 'pt-pt') return 'pt-PT'
+ if (lower === 'pt-br') return 'pt-BR'
+ const base = lower.split('-')[0]
+ if (base === 'pt') return lower.endsWith('-pt') ? 'pt-PT' : 'pt-BR'
+ if (base === 'no' || base === 'nn' || base === 'nb') return 'nb'
+ if (isLocale(base)) return base
+ return null
+}
+
+export function detectLocale(): Locale {
+ try {
+ const saved = globalThis.localStorage?.getItem(STORAGE_KEY)
+ if (saved && isLocale(saved)) return saved
+ } catch {
+ // Storage can be blocked (private mode, disabled site data); fall through to the browser
+ }
+ for (const tag of globalThis.navigator?.languages ?? []) {
+ const match = matchLocale(tag)
+ if (match) return match
+ }
+ return 'en'
+}
+
+async function loadDictionary(locale: Locale): Promise {
+ if (locale === 'en') return en as Dictionary
+ const loader = loaders[`../locales/${locale}.json`]
+ if (!loader) return en as Dictionary // unknown file: fall back rather than show raw keys
+ return (await loader()).default
+}
+
+function applyDocumentLanguage(locale: Locale): void {
+ if (typeof document === 'undefined') return
+ document.documentElement.lang = locale
+ document.documentElement.dir = 'ltr'
+}
+
+/** Loads the saved or detected language before the first render. */
+export async function initI18n(): Promise {
+ await setLocale(detectLocale(), { persist: false })
+}
+
+/** Switches language. Components re-render through useLocale(). */
+export async function setLocale(
+ locale: Locale,
+ options: { persist?: boolean } = { persist: true },
+): Promise {
+ current = await loadDictionary(locale)
+ currentLocale = locale
+ applyDocumentLanguage(locale)
+ if (options.persist !== false) {
+ try {
+ globalThis.localStorage?.setItem(STORAGE_KEY, locale)
+ } catch {
+ // Not saved; the choice lasts for this visit only
+ }
+ }
+ for (const listener of listeners) listener()
+}
+
+export function getLocale(): Locale {
+ return currentLocale
+}
+
+function interpolate(text: string, params: MessageParams = {}): string {
+ return text.replace(/\{(\w+)\}/g, (match, name: string) =>
+ name in params ? String(params[name]) : match,
+ )
+}
+
+/** Translates a message. Counted messages pick the plural form from the `count` param. */
+export function t(key: MessageKey, params?: MessageParams): string {
+ const message: Message = current[key] ?? (en as Dictionary)[key]
+ if (typeof message === 'string') return interpolate(message, params)
+ const count = Number(params?.count ?? 0)
+ const category = new Intl.PluralRules(currentLocale).select(count)
+ return interpolate(category === 'one' ? message.one : message.other, params)
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+}
+
+/** Returns the translator and re-renders the caller when the language changes. */
+export function useT(): (key: MessageKey, params?: MessageParams) => string {
+ useSyncExternalStore(subscribe, getLocale, getLocale)
+ return t
+}
+
+/** Formats a number for the app language, e.g. 12,5 in Swedish and 12.5 in English. */
+export function formatNumber(value: number, fractionDigits = 0): string {
+ return new Intl.NumberFormat(currentLocale, {
+ minimumFractionDigits: fractionDigits,
+ maximumFractionDigits: fractionDigits,
+ }).format(value)
+}
diff --git a/src/index.css b/src/index.css
index de05f79..2fd7c20 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,4 +1,5 @@
:root {
+ --palette-width: 80px;
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
@@ -119,3 +120,34 @@ h2 {
outline: 2px solid var(--accent-border);
outline-offset: 2px;
}
+
+/* Language picker */
+.statusbar-language,
+.style-sheet-language {
+ font: inherit;
+ color: var(--text);
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 2px 4px;
+ max-width: 180px;
+}
+
+.style-sheet-language {
+ display: block;
+ width: 100%;
+ max-width: none;
+ margin: 8px 0;
+ padding: 8px;
+ font-size: 15px;
+}
+
+/* Text in the touch Style sheet: sans-serif, label wraps above the slider instead of colliding with it */
+.style-sheet-text-size {
+ font-family: var(--sans);
+ flex-wrap: wrap;
+}
+
+.style-sheet-text-size > span:first-child {
+ flex: 1 1 100%;
+}
diff --git a/src/locales/da.json b/src/locales/da.json
new file mode 100644
index 0000000..6c7b1d1
--- /dev/null
+++ b/src/locales/da.json
@@ -0,0 +1,232 @@
+{
+ "action.editGpx": "Redigér GPX",
+ "action.editImage": "Redigér billede",
+ "action.export": "Eksportér",
+ "action.importGpx": "Importér GPX",
+ "action.importImage": "Importér billede",
+ "action.installApp": "Installér appen",
+ "action.newProject": "Nyt projekt",
+ "action.openProject": "Åbn projekt",
+ "action.projects": "Projekter",
+ "action.saveProject": "Gem projekt",
+ "action.updateApp": "Opdatér appen",
+ "canvas.importHintDrop": "Importér et kortbillede for at komme i gang, eller slip en fil her.",
+ "canvas.importHintTouch": "Importér et kortbillede for at komme i gang.",
+ "canvas.importMap": "Importér kort",
+ "canvas.loadingSample": "Indlæser eksempel…",
+ "canvas.noMap": "Intet kort indlæst",
+ "canvas.openSample": "Åbn eksempelprojekt",
+ "color.channel": "{channel}-kanal",
+ "color.channelValue": "{channel}-værdi",
+ "color.custom": "Egen farve",
+ "color.hex": "Hex",
+ "color.hexAria": "Hex-farve",
+ "common.apply": "Anvend",
+ "common.cancel": "Annullér",
+ "common.close": "Luk",
+ "common.delete": "Slet",
+ "common.dismiss": "Luk",
+ "common.errorGeneric": "Noget gik galt",
+ "common.loading": "Indlæser…",
+ "common.rename": "Omdøb",
+ "error.body": "O-Analysis stødte på en uventet fejl. Genindlæsning af siden hjælper normalt.",
+ "error.damagedEntry": "Ugyldig {label} i projektfilen (post {index} er beskadiget)",
+ "error.decodeImage": "Billedfilen kunne ikke læses",
+ "error.decodeMap": "Kortbilledet kunne ikke læses",
+ "error.exportTooLarge": "Kortet er for stort til at blive eksporteret på denne enhed",
+ "error.gpxImport": "GPX-importen mislykkedes",
+ "error.imageImport": "Billedimporten mislykkedes",
+ "error.imageTooLarge": "Billedet er for stort ({size}). Den maksimale størrelse er 20 MB.",
+ "error.invalidEntry": "Ugyldig {label} i projektfilen",
+ "error.invalidMap": "Ugyldigt kortbillede i projektfilen",
+ "error.invalidName": "Ugyldigt projektnavn i projektfilen",
+ "error.invalidRotation": "Ugyldig rotation i projektfilen",
+ "error.labelAnnotations": "annoteringer",
+ "error.labelDrawings": "tegninger",
+ "error.labelTracks": "GPX-spor",
+ "error.loadSample": "Eksempelprojektet kunne ikke indlæses",
+ "error.noGpxPoints": "GPX-filen indeholder ingen brugbare sporpunkter",
+ "error.notGpx": "Ikke en gyldig GPX-fil",
+ "error.notImage": "”{name}” er ikke en billedfil",
+ "error.notJson": "Projektfilen er ikke gyldig JSON",
+ "error.notOurs": "Ikke en O-Analysis-projektfil",
+ "error.notProjectFile": "Ikke en projektfil – forventede {ext}",
+ "error.nothingToSave": "Intet at gemme – importér et kort eller tilføj indhold først",
+ "error.openProject": "Projektfilen kunne ikke åbnes",
+ "error.projectNotFound": "Projektet kunne ikke findes",
+ "error.readImage": "Billedfilen kunne ikke læses",
+ "error.reload": "Genindlæs siden",
+ "error.renderExport": "Eksportbilledet kunne ikke tegnes",
+ "error.sampleNotFound": "Eksempelprojektet blev ikke fundet",
+ "error.title": "Noget gik galt",
+ "error.unsupportedFile": "Filtypen understøttes ikke: ”{name}”",
+ "error.version": "Projektversionen understøttes ikke: {version}",
+ "export.download": "Download PNG",
+ "export.exporting": "Eksporterer…",
+ "export.failed": "Eksporten mislykkedes – prøv igen",
+ "export.fit": "Tilpas",
+ "export.generating": "Genererer forhåndsvisning…",
+ "export.hint": "Rul for at zoome · træk for at flytte",
+ "export.includeSidebar": "Medtag annoteringspanelet",
+ "export.loadingPreview": "Indlæser forhåndsvisning…",
+ "export.previewAlt": "Forhåndsvisning af eksport",
+ "export.resetZoom": "Nulstil zoom",
+ "export.scaleNotice": "Kortet er større, end denne enhed kan eksportere i fuld størrelse, så PNG-filen bliver {percent} % af den oprindelige opløsning.",
+ "export.sidebarWithComments": "Kommentarerne vises på kortet – slå panelet fra for at undgå dubletter.",
+ "export.sidebarWithLabels": "Panelet viser hver markørbogstav med sin kommentar.",
+ "export.textSize": "Tekststørrelse",
+ "export.title": "Eksportér",
+ "export.zoomIn": "Zoom ind",
+ "export.zoomOut": "Zoom ud",
+ "gpx.adjustMap": "Justér på kortet",
+ "gpx.applyCrop": "Anvend beskæring",
+ "gpx.confirm": "Bekræft",
+ "gpx.crop": "Beskær",
+ "gpx.cropAria": "Beskær spor",
+ "gpx.cropError": "Markeringen skal indeholde mindst 2 punkter.",
+ "gpx.cropTitle": "Beskær spor",
+ "gpx.empty": "Ingen GPX-spor indlæst.",
+ "gpx.fast": "Hurtig (grøn)",
+ "gpx.highHr": "Høj puls (lys rød)",
+ "gpx.hr": "Puls",
+ "gpx.km": "{value} km",
+ "gpx.lineStyleAria": "Linjestil",
+ "gpx.lowHr": "Lav puls (mørk rød)",
+ "gpx.noTiming": "Ingen tidsdata i denne GPX-fil – beskær efter afstand",
+ "gpx.opacity": "Opacitet",
+ "gpx.opacityShort": "Opac.",
+ "gpx.pace": "Tempo",
+ "gpx.remove": "Fjern",
+ "gpx.removeAria": "Fjern spor",
+ "gpx.slow": "Langsom (rød)",
+ "gpx.solid": "Helt optrukket",
+ "gpx.strokeWidth": "Linjebredde",
+ "gpx.tipAdjust": "Skift til kortet og træk ankerpunkterne",
+ "gpx.tipCrop": "Beskær sporets start og slutning",
+ "gpx.tipRemove": "Fjern dette spor fra projektet",
+ "gpx.title": "GPX-spor",
+ "gpx.toastTapHint": "Tryk på sporet for at tilføje punkter. Tryk længe på et punkt for at slette det.",
+ "gpx.trackColour": "Sporfarve {color}",
+ "gpx.upload": "Upload",
+ "gpx.width": "Bredde",
+ "language.label": "Sprog",
+ "legal.binding": "Den engelske version af dokumentet er den bindende tekst. Oversættelserne er kun til orientering.",
+ "legal.lastUpdated": "Senest opdateret: {date}",
+ "library.deleteAria": "Slet {name}",
+ "library.empty": "Ingen gemte projekter endnu. Importér et kort for at starte et.",
+ "library.noMap": "Intet kort endnu",
+ "library.open": "Åben",
+ "library.renameAria": "Omdøb {name}",
+ "library.title": "Projekter",
+ "library.updated": "{map} · Opdateret {date}",
+ "menu.file": "Fil",
+ "menu.legal": "Juridisk",
+ "menu.menu": "Menu",
+ "menu.more": "Flere handlinger",
+ "menu.privacy": "Privatlivspolitik",
+ "menu.terms": "Servicevilkår",
+ "notes.centerHint": "Klik for at centrere på kortet",
+ "notes.commentPlaceholder": "Tilføj en kommentar…",
+ "notes.comments": "Kommentarer",
+ "notes.commentsHint": "Vis kommentarer direkte på kortet. Træk i højre kant af en valgt kommentar for at ændre bredden.",
+ "notes.deleteAria": "Slet annotering {label}",
+ "notes.displayAria": "Visning af kortmarkører",
+ "notes.empty": "Ingen annoteringer endnu. Brug markørværktøjet til at tilføje kommentarer på kortet.",
+ "notes.heading": "Annoteringer",
+ "notes.labels": "Etiketter",
+ "notes.labelsHint": "Vis bogstavetiketter på kortmarkørerne",
+ "notes.removeHint": "Fjern denne markør",
+ "notes.toggle": "Noter",
+ "notes.toggleAria": "Vis eller skjul annoteringer",
+ "project.untitled": "Unavngivet projekt",
+ "pwa.dismissUpdate": "Luk opdateringsbeskeden",
+ "pwa.updateAvailable": "En ny version af O-Analysis er tilgængelig.",
+ "pwa.updateNow": "Opdatér nu",
+ "rotation.degrees": "{degrees} grader",
+ "rotation.group": "Kortrotation",
+ "rotation.label": "Rotation",
+ "rotation.reset": "Nulstil rotation",
+ "rotation.resetAria": "Nulstil rotationen til 0 grader",
+ "rotation.rotate": "Roter",
+ "save.failed": "Automatisk gemning mislykkedes",
+ "save.saved": "Gemt {time}",
+ "save.saving": "Gemmer…",
+ "status.coords": "X: {x} Y: {y}",
+ "status.credit": "af Anthony Riley ❤️",
+ "status.fitAria": "Zoom {zoom} %. Tilpas kortet til skærmen",
+ "status.fitHint": "Tilpas kortet til skærmen",
+ "status.legal": "Juridisk",
+ "status.noCoords": "X: — Y: —",
+ "status.privacy": "Privatliv",
+ "status.terms": "Vilkår",
+ "status.textSize": "Tekststørrelse",
+ "status.textSizeShort": "Tekst",
+ "status.tool": "Værktøj: {tool}",
+ "status.zoomIn": "Zoom ind",
+ "status.zoomOut": "Zoom ud",
+ "stroke.color": "Farve",
+ "stroke.colorAria": "Farve {color}",
+ "stroke.custom": "Egen farve",
+ "stroke.customHint": "Vælg en egen farve",
+ "stroke.opacity": "Opacitet",
+ "stroke.opacityHint": "Gennemsigtighed for tegnede linjer og markører",
+ "stroke.useColor": "Brug {color}",
+ "stroke.width": "Bredde",
+ "stroke.widthHint": "Linje- og markørbredde",
+ "style.commentTextSize": "Tekststørrelse for kommentarer",
+ "tool.eraser": "Viskelæder",
+ "tool.eraser.tip": "Klik eller træk hen over linjer og markører for at fjerne dem.",
+ "tool.eraser.touchTip": "Tryk eller træk hen over linjer og markører for at fjerne dem.",
+ "tool.gpx": "Justér GPX",
+ "tool.gpx.tip": "Klik på sporet for at tilføje punkter, træk for at flytte dem, og højreklik på et punkt for at slette det.",
+ "tool.gpx.touchTip": "Tryk på sporet for at tilføje punkter. Tryk længe på et punkt for at slette det.",
+ "tool.gpxNeedsFile": "Importér en GPX-fil først",
+ "tool.gpxShort": "GPX",
+ "tool.hotkeyLabel": "{tip} ({key})",
+ "tool.line": "Tegn",
+ "tool.line.tip": "Klik og træk for at tegne linjer på kortet.",
+ "tool.line.touchTip": "Træk fingeren for at tegne linjer på kortet.",
+ "tool.marker": "Markør",
+ "tool.marker.tip": "Klik for at placere en nummereret markør med en kommentar.",
+ "tool.marker.touchTip": "Tryk for at placere en nummereret markør med en kommentar.",
+ "tool.pan": "Flyt",
+ "tool.pan.tip": "Træk kortet for at bevæge dig rundt. Hold mellemrum eller midterknappen nede for midlertidig flytning.",
+ "tool.pan.touchTip": "Træk med ét finger, eller brug to fingre for at flytte i alle værktøjer.",
+ "tool.select": "Vælg",
+ "tool.select.tip": "Klik på linjer eller markører for at vælge dem. Tryk på Delete for at fjerne.",
+ "tool.select.touchTip": "Tryk på linjer eller markører for at vælge dem.",
+ "toolbar.annotations": "Annoteringer",
+ "toolbar.drawingTools": "Tegneværktøjer",
+ "toolbar.notes": "Noter",
+ "toolbar.strokeSettings": "Linjeindstillinger",
+ "toolbar.style": "Stil",
+ "toolbar.viewAndStroke": "Visning og linjeindstillinger",
+ "top.dismissError": "Luk fejlbeskeden",
+ "top.editGpxHint": "Ændr sporfarve, bredde eller justering",
+ "top.exportDisabled": "Importér et kortbillede, før du eksporterer",
+ "top.exportHint": "Forhåndsvis og download kortet med tegninger og annoteringer",
+ "top.history": "Historik",
+ "top.importGpxHint": "Importér en GPX-fil for at lægge en rute over kortet",
+ "top.importImageHint": "Upload et kortbillede – PNG, JPG, eller slip en fil på lærredet",
+ "top.installApp": "Installér appen",
+ "top.installHint": "Installér O-Analysis som en desktopapp",
+ "top.new": "Ny",
+ "top.newHint": "Ryd lærredet og start forfra",
+ "top.open": "Åbn",
+ "top.openHint": "Åbn et gemt projekt ({ext})",
+ "top.projectName": "Projektnavn",
+ "top.projects": "Projekter",
+ "top.projectsHint": "Skift mellem gemte projekter",
+ "top.redo": "Gentag",
+ "top.redoHint": "Gentag (⌘⇧Z / Ctrl+Shift+Z)",
+ "top.replaceImageHint": "Erstat det aktuelle kortbillede",
+ "top.save": "Gem",
+ "top.saveDisabled": "Intet at gemme endnu – importér et kort eller tilføj annoteringer først",
+ "top.saveHint": "Gem projektet som {ext}",
+ "top.undo": "Fortryd",
+ "top.undoHint": "Fortryd sidste handling (⌘Z / Ctrl+Z)",
+ "top.updateApp": "Opdatér appen",
+ "top.updateHint": "Genindlæs for at bruge den seneste version",
+ "touch.dismissTips": "Luk tipsene",
+ "touch.tips": "Knib for at zoome, brug to fingre for at flytte. Tryk på et værktøj og derefter på kortet for at tegne eller placere markører."
+}
diff --git a/src/locales/en.json b/src/locales/en.json
new file mode 100644
index 0000000..4d6ea43
--- /dev/null
+++ b/src/locales/en.json
@@ -0,0 +1,232 @@
+{
+ "action.editGpx": "Edit GPX",
+ "action.editImage": "Edit Image",
+ "action.export": "Export",
+ "action.importGpx": "Import GPX",
+ "action.importImage": "Import Image",
+ "action.installApp": "Install App",
+ "action.newProject": "New Project",
+ "action.openProject": "Open Project",
+ "action.projects": "Projects",
+ "action.saveProject": "Save Project",
+ "action.updateApp": "Update App",
+ "canvas.importHintDrop": "Import a map image to get started, or drop a file here.",
+ "canvas.importHintTouch": "Import a map image to get started.",
+ "canvas.importMap": "Import Map",
+ "canvas.loadingSample": "Loading sample…",
+ "canvas.noMap": "No map loaded",
+ "canvas.openSample": "Open Sample Project",
+ "color.channel": "{channel} channel",
+ "color.channelValue": "{channel} value",
+ "color.custom": "Custom color",
+ "color.hex": "Hex",
+ "color.hexAria": "Hex color",
+ "common.apply": "Apply",
+ "common.cancel": "Cancel",
+ "common.close": "Close",
+ "common.delete": "Delete",
+ "common.dismiss": "Dismiss",
+ "common.errorGeneric": "Something went wrong",
+ "common.loading": "Loading…",
+ "common.rename": "Rename",
+ "error.body": "O-Analysis ran into an unexpected error. Reloading the page usually fixes this.",
+ "error.damagedEntry": "Invalid {label} in project file (entry {index} is damaged)",
+ "error.decodeImage": "Could not decode the image file",
+ "error.decodeMap": "Could not decode map image",
+ "error.exportTooLarge": "This map is too large to export on this device",
+ "error.gpxImport": "GPX import failed",
+ "error.imageImport": "Image import failed",
+ "error.imageTooLarge": "Image is too large ({size}). Maximum size is 20MB.",
+ "error.invalidEntry": "Invalid {label} in project file",
+ "error.invalidMap": "Invalid map image in project file",
+ "error.invalidName": "Invalid project name in project file",
+ "error.invalidRotation": "Invalid rotation in project file",
+ "error.labelAnnotations": "annotations",
+ "error.labelDrawings": "drawings",
+ "error.labelTracks": "GPX tracks",
+ "error.loadSample": "Failed to load sample project",
+ "error.noGpxPoints": "GPX file contains no usable track points",
+ "error.notGpx": "Not a valid GPX file",
+ "error.notImage": "\"{name}\" is not an image file",
+ "error.notJson": "Project file is not valid JSON",
+ "error.notOurs": "Not an O-Analysis project file",
+ "error.notProjectFile": "Not a project file — expected {ext}",
+ "error.nothingToSave": "Nothing to save — import a map or add content first",
+ "error.openProject": "Failed to open project file",
+ "error.projectNotFound": "That project could not be found",
+ "error.readImage": "Could not read the image file",
+ "error.reload": "Reload page",
+ "error.renderExport": "Failed to render export image",
+ "error.sampleNotFound": "Sample project not found",
+ "error.title": "Something went wrong",
+ "error.unsupportedFile": "Unsupported file type: \"{name}\"",
+ "error.version": "Unsupported project version: {version}",
+ "export.download": "Download PNG",
+ "export.exporting": "Exporting…",
+ "export.failed": "Export failed — please try again",
+ "export.fit": "Fit",
+ "export.generating": "Generating preview…",
+ "export.hint": "Scroll to zoom · drag to pan",
+ "export.includeSidebar": "Include annotation sidebar",
+ "export.loadingPreview": "Loading preview…",
+ "export.previewAlt": "Export preview",
+ "export.resetZoom": "Reset zoom",
+ "export.scaleNotice": "This map is larger than this device can export at full size, so the PNG will be {percent}% of native resolution.",
+ "export.sidebarWithComments": "Comments are shown on the map — turn off the sidebar to avoid duplicating them.",
+ "export.sidebarWithLabels": "The sidebar lists each marker letter with its comment.",
+ "export.textSize": "Text size",
+ "export.title": "Export",
+ "export.zoomIn": "Zoom in",
+ "export.zoomOut": "Zoom out",
+ "gpx.adjustMap": "Adjust on map",
+ "gpx.applyCrop": "Apply crop",
+ "gpx.confirm": "Confirm",
+ "gpx.crop": "Crop",
+ "gpx.cropAria": "Crop track",
+ "gpx.cropError": "Selection must include at least 2 points.",
+ "gpx.cropTitle": "Crop track",
+ "gpx.empty": "No GPX tracks loaded.",
+ "gpx.fast": "Fast (green)",
+ "gpx.highHr": "High HR (bright red)",
+ "gpx.hr": "HR",
+ "gpx.km": "{value} km",
+ "gpx.lineStyleAria": "Line style",
+ "gpx.lowHr": "Low HR (dark red)",
+ "gpx.noTiming": "No timing data in this GPX — crop by distance",
+ "gpx.opacity": "Opacity",
+ "gpx.opacityShort": "Opac",
+ "gpx.pace": "Pace",
+ "gpx.remove": "Remove",
+ "gpx.removeAria": "Remove track",
+ "gpx.slow": "Slow (red)",
+ "gpx.solid": "Solid",
+ "gpx.strokeWidth": "Stroke width",
+ "gpx.tipAdjust": "Switch to map and drag anchor pins",
+ "gpx.tipCrop": "Crop start and end of track",
+ "gpx.tipRemove": "Remove this track from the project",
+ "gpx.title": "GPX tracks",
+ "gpx.toastTapHint": "Tap the track to add pins. Long-press a pin to delete.",
+ "gpx.trackColour": "Track colour {color}",
+ "gpx.upload": "Upload",
+ "gpx.width": "Width",
+ "language.label": "Language",
+ "legal.binding": "The English version of this document is the binding text. Translations are provided for convenience.",
+ "legal.lastUpdated": "Last updated: {date}",
+ "library.deleteAria": "Delete {name}",
+ "library.empty": "No saved projects yet. Import a map to start one.",
+ "library.noMap": "No map yet",
+ "library.open": "Open",
+ "library.renameAria": "Rename {name}",
+ "library.title": "Projects",
+ "library.updated": "{map} · Updated {date}",
+ "menu.file": "File",
+ "menu.legal": "Legal",
+ "menu.menu": "Menu",
+ "menu.more": "More actions",
+ "menu.privacy": "Privacy Policy",
+ "menu.terms": "Terms of Service",
+ "notes.centerHint": "Click to center on the map",
+ "notes.commentPlaceholder": "Add a comment…",
+ "notes.comments": "Comments",
+ "notes.commentsHint": "Show inline comments on the map. Drag the right edge of a selected comment to change its width.",
+ "notes.deleteAria": "Delete annotation {label}",
+ "notes.displayAria": "Map marker display",
+ "notes.empty": "No annotations yet. Use the marker tool to add comments to the map.",
+ "notes.heading": "Annotations",
+ "notes.labels": "Labels",
+ "notes.labelsHint": "Show letter labels on map markers",
+ "notes.removeHint": "Remove this marker",
+ "notes.toggle": "Notes",
+ "notes.toggleAria": "Toggle annotations",
+ "project.untitled": "Untitled project",
+ "pwa.dismissUpdate": "Dismiss update notice",
+ "pwa.updateAvailable": "A new version of O-Analysis is available.",
+ "pwa.updateNow": "Update now",
+ "rotation.degrees": "{degrees} degrees",
+ "rotation.group": "Canvas rotation",
+ "rotation.label": "Rotation",
+ "rotation.reset": "Reset rotation",
+ "rotation.resetAria": "Reset rotation to 0 degrees",
+ "rotation.rotate": "Rotate",
+ "save.failed": "Autosave failed",
+ "save.saved": "Saved {time}",
+ "save.saving": "Saving…",
+ "status.coords": "X: {x} Y: {y}",
+ "status.credit": "by Anthony Riley ❤️",
+ "status.fitAria": "Zoom {zoom}%. Fit map to screen",
+ "status.fitHint": "Fit the map to the screen",
+ "status.legal": "Legal",
+ "status.noCoords": "X: — Y: —",
+ "status.privacy": "Privacy",
+ "status.terms": "Terms",
+ "status.textSize": "Text Size",
+ "status.textSizeShort": "Text",
+ "status.tool": "Tool: {tool}",
+ "status.zoomIn": "Zoom in",
+ "status.zoomOut": "Zoom out",
+ "stroke.color": "Color",
+ "stroke.colorAria": "Color {color}",
+ "stroke.custom": "Custom color",
+ "stroke.customHint": "Pick a custom color",
+ "stroke.opacity": "Opacity",
+ "stroke.opacityHint": "Transparency for drawn lines and markers",
+ "stroke.useColor": "Use {color}",
+ "stroke.width": "Width",
+ "stroke.widthHint": "Line and marker stroke width",
+ "style.commentTextSize": "Comment text size",
+ "tool.eraser": "Eraser",
+ "tool.eraser.tip": "Click or drag over paths and markers to remove them.",
+ "tool.eraser.touchTip": "Tap or drag over paths and markers to remove them.",
+ "tool.gpx": "Adjust GPX",
+ "tool.gpx.tip": "Click the track to add pins, drag to move them, right-click a pin to delete it.",
+ "tool.gpx.touchTip": "Tap the track to add pins. Long-press a pin to delete it.",
+ "tool.gpxNeedsFile": "Import a GPX file first",
+ "tool.gpxShort": "GPX",
+ "tool.hotkeyLabel": "{tip} ({key})",
+ "tool.line": "Draw",
+ "tool.line.tip": "Click and drag to draw lines on the map.",
+ "tool.line.touchTip": "Drag your finger to draw lines on the map.",
+ "tool.marker": "Marker",
+ "tool.marker.tip": "Click to place a numbered marker with a comment.",
+ "tool.marker.touchTip": "Tap to place a numbered marker with a comment.",
+ "tool.pan": "Pan",
+ "tool.pan.tip": "Drag the map to move around. Hold Space or middle mouse for temporary pan.",
+ "tool.pan.touchTip": "Drag with one finger, or use two fingers to pan in any tool.",
+ "tool.select": "Select",
+ "tool.select.tip": "Click paths or markers to select them. Press Delete to remove.",
+ "tool.select.touchTip": "Tap paths or markers to select them.",
+ "toolbar.annotations": "Annotations",
+ "toolbar.drawingTools": "Drawing tools",
+ "toolbar.notes": "Notes",
+ "toolbar.strokeSettings": "Stroke settings",
+ "toolbar.style": "Style",
+ "toolbar.viewAndStroke": "View & stroke settings",
+ "top.dismissError": "Dismiss error",
+ "top.editGpxHint": "Change track colours, width, or alignment",
+ "top.exportDisabled": "Import a map image before exporting",
+ "top.exportHint": "Preview and download the map with drawings and annotations",
+ "top.history": "History",
+ "top.importGpxHint": "Import a GPX file to overlay a route on the map",
+ "top.importImageHint": "Upload a map image — PNG, JPG, or drop a file on the canvas",
+ "top.installApp": "Install App",
+ "top.installHint": "Install O-Analysis as a desktop app",
+ "top.new": "New",
+ "top.newHint": "Clear the canvas and start fresh",
+ "top.open": "Open",
+ "top.openHint": "Open a saved project ({ext})",
+ "top.projectName": "Project name",
+ "top.projects": "Projects",
+ "top.projectsHint": "Switch between saved projects",
+ "top.redo": "Redo",
+ "top.redoHint": "Redo (⌘⇧Z / Ctrl+Shift+Z)",
+ "top.replaceImageHint": "Replace the current map image",
+ "top.save": "Save",
+ "top.saveDisabled": "Nothing to save yet — import a map or add annotations first",
+ "top.saveHint": "Save your project as {ext}",
+ "top.undo": "Undo",
+ "top.undoHint": "Undo last action (⌘Z / Ctrl+Z)",
+ "top.updateApp": "Update App",
+ "top.updateHint": "Reload to use the latest version",
+ "touch.dismissTips": "Dismiss tips",
+ "touch.tips": "Pinch to zoom, use two fingers to pan. Tap a tool, then tap the map to draw or place markers."
+}
diff --git a/src/locales/es.json b/src/locales/es.json
new file mode 100644
index 0000000..4a65b9f
--- /dev/null
+++ b/src/locales/es.json
@@ -0,0 +1,232 @@
+{
+ "action.editGpx": "Editar GPX",
+ "action.editImage": "Editar imagen",
+ "action.export": "Exportar",
+ "action.importGpx": "Importar GPX",
+ "action.importImage": "Importar imagen",
+ "action.installApp": "Instalar la app",
+ "action.newProject": "Nuevo proyecto",
+ "action.openProject": "Abrir proyecto",
+ "action.projects": "Proyectos",
+ "action.saveProject": "Guardar proyecto",
+ "action.updateApp": "Actualizar la app",
+ "canvas.importHintDrop": "Importa una imagen de mapa para empezar, o suelta un archivo aquí.",
+ "canvas.importHintTouch": "Importa una imagen de mapa para empezar.",
+ "canvas.importMap": "Importar mapa",
+ "canvas.loadingSample": "Cargando ejemplo…",
+ "canvas.noMap": "No hay ningún mapa cargado",
+ "canvas.openSample": "Abrir proyecto de ejemplo",
+ "color.channel": "Canal {channel}",
+ "color.channelValue": "Valor de {channel}",
+ "color.custom": "Color personalizado",
+ "color.hex": "Hex",
+ "color.hexAria": "Color hexadecimal",
+ "common.apply": "Aplicar",
+ "common.cancel": "Cancelar",
+ "common.close": "Cerrar",
+ "common.delete": "Eliminar",
+ "common.dismiss": "Descartar",
+ "common.errorGeneric": "Algo salió mal",
+ "common.loading": "Cargando…",
+ "common.rename": "Cambiar nombre",
+ "error.body": "O-Analysis encontró un error inesperado. Recargar la página suele solucionarlo.",
+ "error.damagedEntry": "{label} no válido en el archivo del proyecto (la entrada {index} está dañada)",
+ "error.decodeImage": "No se pudo leer el archivo de imagen",
+ "error.decodeMap": "No se pudo leer la imagen del mapa",
+ "error.exportTooLarge": "El mapa es demasiado grande para exportarlo en este dispositivo",
+ "error.gpxImport": "No se pudo importar el GPX",
+ "error.imageImport": "No se pudo importar la imagen",
+ "error.imageTooLarge": "La imagen es demasiado grande ({size}). El tamaño máximo es de 20 MB.",
+ "error.invalidEntry": "{label} no válido en el archivo del proyecto",
+ "error.invalidMap": "Imagen de mapa no válida en el archivo del proyecto",
+ "error.invalidName": "Nombre de proyecto no válido en el archivo del proyecto",
+ "error.invalidRotation": "Rotación no válida en el archivo del proyecto",
+ "error.labelAnnotations": "anotaciones",
+ "error.labelDrawings": "dibujos",
+ "error.labelTracks": "recorridos GPX",
+ "error.loadSample": "No se pudo cargar el proyecto de ejemplo",
+ "error.noGpxPoints": "El archivo GPX no contiene puntos de recorrido utilizables",
+ "error.notGpx": "No es un archivo GPX válido",
+ "error.notImage": "«{name}» no es un archivo de imagen",
+ "error.notJson": "El archivo del proyecto no es JSON válido",
+ "error.notOurs": "No es un archivo de proyecto de O-Analysis",
+ "error.notProjectFile": "No es un archivo de proyecto; se esperaba {ext}",
+ "error.nothingToSave": "No hay nada que guardar; importa un mapa o añade contenido primero",
+ "error.openProject": "No se pudo abrir el archivo del proyecto",
+ "error.projectNotFound": "No se pudo encontrar el proyecto",
+ "error.readImage": "No se pudo leer el archivo de imagen",
+ "error.reload": "Recargar página",
+ "error.renderExport": "No se pudo generar la imagen de exportación",
+ "error.sampleNotFound": "No se encontró el proyecto de ejemplo",
+ "error.title": "Algo salió mal",
+ "error.unsupportedFile": "Tipo de archivo no compatible: «{name}»",
+ "error.version": "Versión de proyecto no compatible: {version}",
+ "export.download": "Descargar PNG",
+ "export.exporting": "Exportando…",
+ "export.failed": "La exportación falló; inténtalo de nuevo",
+ "export.fit": "Ajustar",
+ "export.generating": "Generando vista previa…",
+ "export.hint": "Desplázate para hacer zoom · arrastra para mover",
+ "export.includeSidebar": "Incluir panel de anotaciones",
+ "export.loadingPreview": "Cargando vista previa…",
+ "export.previewAlt": "Vista previa de la exportación",
+ "export.resetZoom": "Restablecer zoom",
+ "export.scaleNotice": "El mapa es más grande de lo que este dispositivo puede exportar a tamaño completo, así que el PNG será del {percent} % de la resolución original.",
+ "export.sidebarWithComments": "Los comentarios aparecen en el mapa; desactiva el panel para no duplicarlos.",
+ "export.sidebarWithLabels": "El panel muestra cada letra de marcador con su comentario.",
+ "export.textSize": "Tamaño del texto",
+ "export.title": "Exportar",
+ "export.zoomIn": "Acercar",
+ "export.zoomOut": "Alejar",
+ "gpx.adjustMap": "Ajustar en el mapa",
+ "gpx.applyCrop": "Aplicar recorte",
+ "gpx.confirm": "Confirmar",
+ "gpx.crop": "Recortar",
+ "gpx.cropAria": "Recortar recorrido",
+ "gpx.cropError": "La selección debe incluir al menos 2 puntos.",
+ "gpx.cropTitle": "Recortar recorrido",
+ "gpx.empty": "No hay recorridos GPX cargados.",
+ "gpx.fast": "Rápido (verde)",
+ "gpx.highHr": "FC alta (rojo intenso)",
+ "gpx.hr": "FC",
+ "gpx.km": "{value} km",
+ "gpx.lineStyleAria": "Estilo de línea",
+ "gpx.lowHr": "FC baja (rojo oscuro)",
+ "gpx.noTiming": "Este GPX no tiene datos de tiempo; recorta por distancia",
+ "gpx.opacity": "Opacidad",
+ "gpx.opacityShort": "Opac.",
+ "gpx.pace": "Ritmo",
+ "gpx.remove": "Quitar",
+ "gpx.removeAria": "Quitar recorrido",
+ "gpx.slow": "Lento (rojo)",
+ "gpx.solid": "Sólido",
+ "gpx.strokeWidth": "Grosor de línea",
+ "gpx.tipAdjust": "Cambia al mapa y arrastra los pines de anclaje",
+ "gpx.tipCrop": "Recorta el inicio y el final del recorrido",
+ "gpx.tipRemove": "Quita este recorrido del proyecto",
+ "gpx.title": "Recorridos GPX",
+ "gpx.toastTapHint": "Toca el recorrido para añadir pines. Mantén pulsado un pin para eliminarlo.",
+ "gpx.trackColour": "Color del recorrido {color}",
+ "gpx.upload": "Subir",
+ "gpx.width": "Ancho",
+ "language.label": "Idioma",
+ "legal.binding": "La versión inglesa de este documento es el texto vinculante. Las traducciones se ofrecen solo como referencia.",
+ "legal.lastUpdated": "Última actualización: {date}",
+ "library.deleteAria": "Eliminar {name}",
+ "library.empty": "Todavía no hay proyectos guardados. Importa un mapa para empezar uno.",
+ "library.noMap": "Sin mapa todavía",
+ "library.open": "Abierto",
+ "library.renameAria": "Cambiar el nombre de {name}",
+ "library.title": "Proyectos",
+ "library.updated": "{map} · Actualizado el {date}",
+ "menu.file": "Archivo",
+ "menu.legal": "Legal",
+ "menu.menu": "Menú",
+ "menu.more": "Más acciones",
+ "menu.privacy": "Política de privacidad",
+ "menu.terms": "Términos de servicio",
+ "notes.centerHint": "Haz clic para centrar en el mapa",
+ "notes.commentPlaceholder": "Añade un comentario…",
+ "notes.comments": "Comentarios",
+ "notes.commentsHint": "Muestra comentarios en línea en el mapa. Arrastra el borde derecho de un comentario seleccionado para cambiar su ancho.",
+ "notes.deleteAria": "Eliminar anotación {label}",
+ "notes.displayAria": "Visualización de marcadores del mapa",
+ "notes.empty": "Todavía no hay anotaciones. Usa la herramienta de marcador para añadir comentarios al mapa.",
+ "notes.heading": "Anotaciones",
+ "notes.labels": "Etiquetas",
+ "notes.labelsHint": "Muestra letras en los marcadores del mapa",
+ "notes.removeHint": "Eliminar este marcador",
+ "notes.toggle": "Notas",
+ "notes.toggleAria": "Mostrar u ocultar anotaciones",
+ "project.untitled": "Proyecto sin título",
+ "pwa.dismissUpdate": "Descartar el aviso de actualización",
+ "pwa.updateAvailable": "Hay una nueva versión de O-Analysis disponible.",
+ "pwa.updateNow": "Actualizar ahora",
+ "rotation.degrees": "{degrees} grados",
+ "rotation.group": "Rotación del lienzo",
+ "rotation.label": "Rotación",
+ "rotation.reset": "Restablecer rotación",
+ "rotation.resetAria": "Restablecer la rotación a 0 grados",
+ "rotation.rotate": "Girar",
+ "save.failed": "Error al guardar automáticamente",
+ "save.saved": "Guardado a las {time}",
+ "save.saving": "Guardando…",
+ "status.coords": "X: {x} Y: {y}",
+ "status.credit": "por Anthony Riley ❤️",
+ "status.fitAria": "Zoom {zoom} %. Ajustar el mapa a la pantalla",
+ "status.fitHint": "Ajustar el mapa a la pantalla",
+ "status.legal": "Legal",
+ "status.noCoords": "X: — Y: —",
+ "status.privacy": "Privacidad",
+ "status.terms": "Términos",
+ "status.textSize": "Tamaño del texto",
+ "status.textSizeShort": "Texto",
+ "status.tool": "Herramienta: {tool}",
+ "status.zoomIn": "Acercar",
+ "status.zoomOut": "Alejar",
+ "stroke.color": "Color",
+ "stroke.colorAria": "Color {color}",
+ "stroke.custom": "Color personalizado",
+ "stroke.customHint": "Elige un color personalizado",
+ "stroke.opacity": "Opacidad",
+ "stroke.opacityHint": "Transparencia de líneas y marcadores dibujados",
+ "stroke.useColor": "Usar {color}",
+ "stroke.width": "Ancho",
+ "stroke.widthHint": "Grosor de línea y de marcador",
+ "style.commentTextSize": "Tamaño del texto de los comentarios",
+ "tool.eraser": "Borrador",
+ "tool.eraser.tip": "Haz clic o arrastra sobre trazos y marcadores para eliminarlos.",
+ "tool.eraser.touchTip": "Toca o arrastra sobre trazos y marcadores para eliminarlos.",
+ "tool.gpx": "Ajustar GPX",
+ "tool.gpx.tip": "Haz clic en el recorrido para añadir pines, arrástralos para moverlos y haz clic derecho en un pin para eliminarlo.",
+ "tool.gpx.touchTip": "Toca el recorrido para añadir pines. Mantén pulsado un pin para eliminarlo.",
+ "tool.gpxNeedsFile": "Importa primero un archivo GPX",
+ "tool.gpxShort": "GPX",
+ "tool.hotkeyLabel": "{tip} ({key})",
+ "tool.line": "Dibujar",
+ "tool.line.tip": "Haz clic y arrastra para dibujar líneas en el mapa.",
+ "tool.line.touchTip": "Arrastra con el dedo para dibujar líneas en el mapa.",
+ "tool.marker": "Marcador",
+ "tool.marker.tip": "Haz clic para colocar un marcador numerado con un comentario.",
+ "tool.marker.touchTip": "Toca para colocar un marcador numerado con un comentario.",
+ "tool.pan": "Mover",
+ "tool.pan.tip": "Arrastra el mapa para desplazarte. Mantén pulsada la barra espaciadora o el botón central para moverlo temporalmente.",
+ "tool.pan.touchTip": "Arrastra con un dedo, o usa dos dedos para mover el mapa en cualquier herramienta.",
+ "tool.select": "Seleccionar",
+ "tool.select.tip": "Haz clic en trazos o marcadores para seleccionarlos. Pulsa Supr para eliminar.",
+ "tool.select.touchTip": "Toca trazos o marcadores para seleccionarlos.",
+ "toolbar.annotations": "Anotaciones",
+ "toolbar.drawingTools": "Herramientas de dibujo",
+ "toolbar.notes": "Notas",
+ "toolbar.strokeSettings": "Ajustes de trazo",
+ "toolbar.style": "Estilo",
+ "toolbar.viewAndStroke": "Vista y ajustes de trazo",
+ "top.dismissError": "Descartar el error",
+ "top.editGpxHint": "Cambia el color, el ancho o la alineación del recorrido",
+ "top.exportDisabled": "Importa una imagen de mapa antes de exportar",
+ "top.exportHint": "Previsualiza y descarga el mapa con dibujos y anotaciones",
+ "top.history": "Historial",
+ "top.importGpxHint": "Importa un archivo GPX para superponer una ruta en el mapa",
+ "top.importImageHint": "Sube una imagen de mapa (PNG, JPG) o suelta un archivo en el lienzo",
+ "top.installApp": "Instalar la app",
+ "top.installHint": "Instala O-Analysis como app de escritorio",
+ "top.new": "Nuevo",
+ "top.newHint": "Limpia el lienzo y empieza de nuevo",
+ "top.open": "Abrir",
+ "top.openHint": "Abre un proyecto guardado ({ext})",
+ "top.projectName": "Nombre del proyecto",
+ "top.projects": "Proyectos",
+ "top.projectsHint": "Cambia entre proyectos guardados",
+ "top.redo": "Rehacer",
+ "top.redoHint": "Rehacer (⌘⇧Z / Ctrl+Shift+Z)",
+ "top.replaceImageHint": "Reemplaza la imagen de mapa actual",
+ "top.save": "Guardar",
+ "top.saveDisabled": "Todavía no hay nada que guardar; importa un mapa o añade anotaciones primero",
+ "top.saveHint": "Guarda el proyecto como {ext}",
+ "top.undo": "Deshacer",
+ "top.undoHint": "Deshacer la última acción (⌘Z / Ctrl+Z)",
+ "top.updateApp": "Actualizar la app",
+ "top.updateHint": "Recarga para usar la versión más reciente",
+ "touch.dismissTips": "Descartar los consejos",
+ "touch.tips": "Pellizca para hacer zoom y usa dos dedos para desplazarte. Toca una herramienta y luego el mapa para dibujar o colocar marcadores."
+}
diff --git a/src/locales/fi.json b/src/locales/fi.json
new file mode 100644
index 0000000..79566ba
--- /dev/null
+++ b/src/locales/fi.json
@@ -0,0 +1,232 @@
+{
+ "action.editGpx": "Muokkaa GPX:ää",
+ "action.editImage": "Muokkaa kuvaa",
+ "action.export": "Vie",
+ "action.importGpx": "Tuo GPX",
+ "action.importImage": "Tuo kuva",
+ "action.installApp": "Asenna sovellus",
+ "action.newProject": "Uusi projekti",
+ "action.openProject": "Avaa projekti",
+ "action.projects": "Projektit",
+ "action.saveProject": "Tallenna projekti",
+ "action.updateApp": "Päivitä sovellus",
+ "canvas.importHintDrop": "Aloita tuomalla karttakuva tai pudota tiedosto tähän.",
+ "canvas.importHintTouch": "Aloita tuomalla karttakuva.",
+ "canvas.importMap": "Tuo kartta",
+ "canvas.loadingSample": "Ladataan esimerkkiä…",
+ "canvas.noMap": "Karttaa ei ole ladattu",
+ "canvas.openSample": "Avaa esimerkkiprojekti",
+ "color.channel": "{channel}-kanava",
+ "color.channelValue": "{channel}-arvo",
+ "color.custom": "Oma väri",
+ "color.hex": "Hex",
+ "color.hexAria": "Hex-väri",
+ "common.apply": "Käytä",
+ "common.cancel": "Peruuta",
+ "common.close": "Sulje",
+ "common.delete": "Poista",
+ "common.dismiss": "Sulje",
+ "common.errorGeneric": "Jotain meni pieleen",
+ "common.loading": "Ladataan…",
+ "common.rename": "Nimeä uudelleen",
+ "error.body": "O-Analysis kohtasi odottamattoman virheen. Sivun uudelleenlataus auttaa yleensä.",
+ "error.damagedEntry": "Virheellinen {label} projektitiedostossa (kohta {index} on vioittunut)",
+ "error.decodeImage": "Kuvatiedostoa ei voitu lukea",
+ "error.decodeMap": "Karttakuvaa ei voitu lukea",
+ "error.exportTooLarge": "Kartta on liian suuri vietäväksi tällä laitteella",
+ "error.gpxImport": "GPX-tuonti epäonnistui",
+ "error.imageImport": "Kuvan tuonti epäonnistui",
+ "error.imageTooLarge": "Kuva on liian suuri ({size}). Enimmäiskoko on 20 Mt.",
+ "error.invalidEntry": "Virheellinen {label} projektitiedostossa",
+ "error.invalidMap": "Virheellinen karttakuva projektitiedostossa",
+ "error.invalidName": "Virheellinen projektin nimi projektitiedostossa",
+ "error.invalidRotation": "Virheellinen kierto projektitiedostossa",
+ "error.labelAnnotations": "merkinnät",
+ "error.labelDrawings": "piirrokset",
+ "error.labelTracks": "GPX-reitit",
+ "error.loadSample": "Esimerkkiprojektin lataus epäonnistui",
+ "error.noGpxPoints": "GPX-tiedostossa ei ole käytettäviä reittipisteitä",
+ "error.notGpx": "Ei kelvollinen GPX-tiedosto",
+ "error.notImage": "”{name}” ei ole kuvatiedosto",
+ "error.notJson": "Projektitiedosto ei ole kelvollista JSONia",
+ "error.notOurs": "Ei ole O-Analysis-projektitiedosto",
+ "error.notProjectFile": "Ei projektitiedosto – odotettiin {ext}",
+ "error.nothingToSave": "Ei mitään tallennettavaa – tuo kartta tai lisää sisältöä ensin",
+ "error.openProject": "Projektitiedoston avaaminen epäonnistui",
+ "error.projectNotFound": "Projektia ei löytynyt",
+ "error.readImage": "Kuvatiedostoa ei voitu lukea",
+ "error.reload": "Lataa sivu uudelleen",
+ "error.renderExport": "Vientikuvan piirtäminen epäonnistui",
+ "error.sampleNotFound": "Esimerkkiprojektia ei löytynyt",
+ "error.title": "Jotain meni pieleen",
+ "error.unsupportedFile": "Tiedostotyyppi ei ole tuettu: ”{name}”",
+ "error.version": "Projektiversio ei ole tuettu: {version}",
+ "export.download": "Lataa PNG",
+ "export.exporting": "Viedään…",
+ "export.failed": "Vienti epäonnistui – yritä uudelleen",
+ "export.fit": "Sovita",
+ "export.generating": "Luodaan esikatselua…",
+ "export.hint": "Zoomaa vierittämällä · siirrä vetämällä",
+ "export.includeSidebar": "Sisällytä merkintäpaneeli",
+ "export.loadingPreview": "Ladataan esikatselua…",
+ "export.previewAlt": "Viennin esikatselu",
+ "export.resetZoom": "Palauta zoomaus",
+ "export.scaleNotice": "Kartta on suurempi kuin tämä laite voi viedä täydessä koossa, joten PNG-kuva on {percent} % alkuperäisestä resoluutiosta.",
+ "export.sidebarWithComments": "Kommentit näkyvät kartalla – poista paneeli käytöstä välttääksesi päällekkäisyyden.",
+ "export.sidebarWithLabels": "Paneeli listaa jokaisen merkin kirjaimen kommentteineen.",
+ "export.textSize": "Tekstin koko",
+ "export.title": "Vie",
+ "export.zoomIn": "Lähennä",
+ "export.zoomOut": "Loitonna",
+ "gpx.adjustMap": "Säädä kartalla",
+ "gpx.applyCrop": "Käytä rajausta",
+ "gpx.confirm": "Vahvista",
+ "gpx.crop": "Rajaa",
+ "gpx.cropAria": "Rajaa reitti",
+ "gpx.cropError": "Valinnassa on oltava vähintään 2 pistettä.",
+ "gpx.cropTitle": "Rajaa reitti",
+ "gpx.empty": "Ei ladattuja GPX-reittejä.",
+ "gpx.fast": "Nopea (vihreä)",
+ "gpx.highHr": "Korkea syke (kirkkaanpunainen)",
+ "gpx.hr": "Syke",
+ "gpx.km": "{value} km",
+ "gpx.lineStyleAria": "Viivatyyli",
+ "gpx.lowHr": "Matala syke (tummanpunainen)",
+ "gpx.noTiming": "Tässä GPX-tiedostossa ei ole aikatietoja – rajaa matkan mukaan",
+ "gpx.opacity": "Peittävyys",
+ "gpx.opacityShort": "Peitt.",
+ "gpx.pace": "Vauhti",
+ "gpx.remove": "Poista",
+ "gpx.removeAria": "Poista reitti",
+ "gpx.slow": "Hidas (punainen)",
+ "gpx.solid": "Yhtenäinen",
+ "gpx.strokeWidth": "Viivan leveys",
+ "gpx.tipAdjust": "Siirry karttaan ja vedä ankkuripisteitä",
+ "gpx.tipCrop": "Rajaa reitin alku ja loppu",
+ "gpx.tipRemove": "Poista tämä reitti projektista",
+ "gpx.title": "GPX-reitit",
+ "gpx.toastTapHint": "Napauta reittiä lisätäksesi pisteitä. Poista piste painamalla pitkään.",
+ "gpx.trackColour": "Reitin väri {color}",
+ "gpx.upload": "Lataa",
+ "gpx.width": "Leveys",
+ "language.label": "Kieli",
+ "legal.binding": "Asiakirjan englanninkielinen versio on sitova teksti. Käännökset on tarjottu vain tiedoksi.",
+ "legal.lastUpdated": "Viimeksi päivitetty: {date}",
+ "library.deleteAria": "Poista {name}",
+ "library.empty": "Ei vielä tallennettuja projekteja. Aloita tuomalla kartta.",
+ "library.noMap": "Ei vielä karttaa",
+ "library.open": "Avoin",
+ "library.renameAria": "Nimeä {name} uudelleen",
+ "library.title": "Projektit",
+ "library.updated": "{map} · Päivitetty {date}",
+ "menu.file": "Tiedosto",
+ "menu.legal": "Oikeudellista",
+ "menu.menu": "Valikko",
+ "menu.more": "Lisää toimintoja",
+ "menu.privacy": "Tietosuojaseloste",
+ "menu.terms": "Käyttöehdot",
+ "notes.centerHint": "Napsauta keskittääksesi kartalle",
+ "notes.commentPlaceholder": "Lisää kommentti…",
+ "notes.comments": "Kommentit",
+ "notes.commentsHint": "Näytä kommentit suoraan kartalla. Muuta leveyttä vetämällä valitun kommentin oikeaa reunaa.",
+ "notes.deleteAria": "Poista merkintä {label}",
+ "notes.displayAria": "Karttamerkkien näyttö",
+ "notes.empty": "Ei vielä merkintöjä. Lisää kommentteja kartalle merkkityökalulla.",
+ "notes.heading": "Merkinnät",
+ "notes.labels": "Tunnisteet",
+ "notes.labelsHint": "Näytä kirjaintunnisteet karttamerkeissä",
+ "notes.removeHint": "Poista tämä merkki",
+ "notes.toggle": "Muistiinpanot",
+ "notes.toggleAria": "Näytä tai piilota merkinnät",
+ "project.untitled": "Nimetön projekti",
+ "pwa.dismissUpdate": "Sulje päivitysilmoitus",
+ "pwa.updateAvailable": "O-Analysisin uusi versio on saatavilla.",
+ "pwa.updateNow": "Päivitä nyt",
+ "rotation.degrees": "{degrees} astetta",
+ "rotation.group": "Kartan kierto",
+ "rotation.label": "Kierto",
+ "rotation.reset": "Palauta kierto",
+ "rotation.resetAria": "Palauta kierto 0 asteeseen",
+ "rotation.rotate": "Kierrä",
+ "save.failed": "Automaattinen tallennus epäonnistui",
+ "save.saved": "Tallennettu {time}",
+ "save.saving": "Tallennetaan…",
+ "status.coords": "X: {x} Y: {y}",
+ "status.credit": "tekijä Anthony Riley ❤️",
+ "status.fitAria": "Zoomaus {zoom} %. Sovita kartta näytölle",
+ "status.fitHint": "Sovita kartta näytölle",
+ "status.legal": "Oikeudellista",
+ "status.noCoords": "X: — Y: —",
+ "status.privacy": "Tietosuoja",
+ "status.terms": "Ehdot",
+ "status.textSize": "Tekstin koko",
+ "status.textSizeShort": "Teksti",
+ "status.tool": "Työkalu: {tool}",
+ "status.zoomIn": "Lähennä",
+ "status.zoomOut": "Loitonna",
+ "stroke.color": "Väri",
+ "stroke.colorAria": "Väri {color}",
+ "stroke.custom": "Oma väri",
+ "stroke.customHint": "Valitse oma väri",
+ "stroke.opacity": "Peittävyys",
+ "stroke.opacityHint": "Piirrettyjen viivojen ja merkkien läpinäkyvyys",
+ "stroke.useColor": "Käytä väriä {color}",
+ "stroke.width": "Leveys",
+ "stroke.widthHint": "Viivan ja merkin leveys",
+ "style.commentTextSize": "Kommenttien tekstin koko",
+ "tool.eraser": "Pyyhekumi",
+ "tool.eraser.tip": "Poista klikkaamalla tai vetämällä viivojen ja merkkien päältä.",
+ "tool.eraser.touchTip": "Poista napauttamalla tai vetämällä viivojen ja merkkien päältä.",
+ "tool.gpx": "Säädä GPX",
+ "tool.gpx.tip": "Lisää pisteitä klikkaamalla reittiä, siirrä niitä vetämällä ja poista piste oikealla napsautuksella.",
+ "tool.gpx.touchTip": "Lisää pisteitä napauttamalla reittiä. Poista piste painamalla pitkään.",
+ "tool.gpxNeedsFile": "Tuo ensin GPX-tiedosto",
+ "tool.gpxShort": "GPX",
+ "tool.hotkeyLabel": "{tip} ({key})",
+ "tool.line": "Piirrä",
+ "tool.line.tip": "Piirrä viivoja kartalle klikkaamalla ja vetämällä.",
+ "tool.line.touchTip": "Piirrä viivoja kartalle vetämällä sormella.",
+ "tool.marker": "Merkki",
+ "tool.marker.tip": "Klikkaa asettaaksesi numeroidun merkin kommentilla.",
+ "tool.marker.touchTip": "Napauta asettaaksesi numeroidun merkin kommentilla.",
+ "tool.pan": "Siirrä",
+ "tool.pan.tip": "Vedä karttaa liikkuaksesi. Pidä välilyöntiä tai hiiren keskipainiketta pohjassa tilapäiseen siirtoon.",
+ "tool.pan.touchTip": "Vedä yhdellä sormella tai käytä kahta sormea siirtääksesi kartta millä tahansa työkalulla.",
+ "tool.select": "Valitse",
+ "tool.select.tip": "Valitse viivoja tai merkkejä klikkaamalla. Poista painamalla Delete.",
+ "tool.select.touchTip": "Valitse viivoja tai merkkejä napauttamalla.",
+ "toolbar.annotations": "Merkinnät",
+ "toolbar.drawingTools": "Piirrustustyökalut",
+ "toolbar.notes": "Muistiinpanot",
+ "toolbar.strokeSettings": "Viivan asetukset",
+ "toolbar.style": "Tyyli",
+ "toolbar.viewAndStroke": "Näkymä ja viivan asetukset",
+ "top.dismissError": "Sulje virheilmoitus",
+ "top.editGpxHint": "Muuta reitin väriä, leveyttä tai asemointia",
+ "top.exportDisabled": "Tuo karttakuva ennen viennin aloittamista",
+ "top.exportHint": "Esikatsele ja lataa kartta piirroksineen ja merkintöineen",
+ "top.history": "Historia",
+ "top.importGpxHint": "Tuo GPX-tiedosto reitin piirtämiseksi kartalle",
+ "top.importImageHint": "Lataa karttakuva – PNG, JPG tai pudota tiedosto piirtoalueelle",
+ "top.installApp": "Asenna sovellus",
+ "top.installHint": "Asenna O-Analysis työpöytäsovellukseksi",
+ "top.new": "Uusi",
+ "top.newHint": "Tyhjennä piirtoalue ja aloita alusta",
+ "top.open": "Avaa",
+ "top.openHint": "Avaa tallennettu projekti ({ext})",
+ "top.projectName": "Projektin nimi",
+ "top.projects": "Projektit",
+ "top.projectsHint": "Vaihda tallennettujen projektien välillä",
+ "top.redo": "Tee uudelleen",
+ "top.redoHint": "Tee uudelleen (⌘⇧Z / Ctrl+Shift+Z)",
+ "top.replaceImageHint": "Korvaa nykyinen karttakuva",
+ "top.save": "Tallenna",
+ "top.saveDisabled": "Ei vielä tallennettavaa – tuo kartta tai lisää merkintöjä ensin",
+ "top.saveHint": "Tallenna projekti muodossa {ext}",
+ "top.undo": "Kumoa",
+ "top.undoHint": "Kumoa viimeisin toiminto (⌘Z / Ctrl+Z)",
+ "top.updateApp": "Päivitä sovellus",
+ "top.updateHint": "Lataa uudelleen käyttääksesi uusinta versiota",
+ "touch.dismissTips": "Sulje vihjeet",
+ "touch.tips": "Zoomaa nipistämällä, siirrä kahdella sormella. Valitse työkalu napauttamalla ja napauta sitten karttaa piirtääksesi tai asettaaksesi merkkejä."
+}
diff --git a/src/locales/fr.json b/src/locales/fr.json
new file mode 100644
index 0000000..43122e4
--- /dev/null
+++ b/src/locales/fr.json
@@ -0,0 +1,232 @@
+{
+ "action.editGpx": "Modifier le GPX",
+ "action.editImage": "Modifier l’image",
+ "action.export": "Exporter",
+ "action.importGpx": "Importer un GPX",
+ "action.importImage": "Importer une image",
+ "action.installApp": "Installer l’application",
+ "action.newProject": "Nouveau projet",
+ "action.openProject": "Ouvrir un projet",
+ "action.projects": "Projets",
+ "action.saveProject": "Enregistrer le projet",
+ "action.updateApp": "Mettre à jour l’application",
+ "canvas.importHintDrop": "Importez une image de carte pour commencer, ou déposez un fichier ici.",
+ "canvas.importHintTouch": "Importez une image de carte pour commencer.",
+ "canvas.importMap": "Importer une carte",
+ "canvas.loadingSample": "Chargement de l’exemple…",
+ "canvas.noMap": "Aucune carte chargée",
+ "canvas.openSample": "Ouvrir le projet d’exemple",
+ "color.channel": "Canal {channel}",
+ "color.channelValue": "Valeur {channel}",
+ "color.custom": "Couleur personnalisée",
+ "color.hex": "Hex",
+ "color.hexAria": "Couleur hexadécimale",
+ "common.apply": "Appliquer",
+ "common.cancel": "Annuler",
+ "common.close": "Fermer",
+ "common.delete": "Supprimer",
+ "common.dismiss": "Ignorer",
+ "common.errorGeneric": "Une erreur s’est produite",
+ "common.loading": "Chargement…",
+ "common.rename": "Renommer",
+ "error.body": "O-Analysis a rencontré une erreur inattendue. Recharger la page règle généralement le problème.",
+ "error.damagedEntry": "{label} invalide dans le fichier du projet (l’entrée {index} est endommagée)",
+ "error.decodeImage": "Impossible de lire le fichier image",
+ "error.decodeMap": "Impossible de lire l’image de la carte",
+ "error.exportTooLarge": "La carte est trop grande pour être exportée sur cet appareil",
+ "error.gpxImport": "Échec de l’importation du GPX",
+ "error.imageImport": "Échec de l’importation de l’image",
+ "error.imageTooLarge": "L’image est trop volumineuse ({size}). La taille maximale est de 20 Mo.",
+ "error.invalidEntry": "{label} invalide dans le fichier du projet",
+ "error.invalidMap": "Image de carte invalide dans le fichier du projet",
+ "error.invalidName": "Nom de projet invalide dans le fichier du projet",
+ "error.invalidRotation": "Rotation invalide dans le fichier du projet",
+ "error.labelAnnotations": "annotations",
+ "error.labelDrawings": "tracés",
+ "error.labelTracks": "traces GPX",
+ "error.loadSample": "Échec du chargement du projet d’exemple",
+ "error.noGpxPoints": "Le fichier GPX ne contient aucun point de trace exploitable",
+ "error.notGpx": "Fichier GPX invalide",
+ "error.notImage": "« {name} » n’est pas un fichier image",
+ "error.notJson": "Le fichier du projet n’est pas un JSON valide",
+ "error.notOurs": "Ce n’est pas un fichier de projet O-Analysis",
+ "error.notProjectFile": "Ce n’est pas un fichier de projet ; {ext} attendu",
+ "error.nothingToSave": "Rien à enregistrer : importez d’abord une carte ou ajoutez du contenu",
+ "error.openProject": "Impossible d’ouvrir le fichier du projet",
+ "error.projectNotFound": "Projet introuvable",
+ "error.readImage": "Impossible de lire le fichier image",
+ "error.reload": "Recharger la page",
+ "error.renderExport": "Impossible de générer l’image d’export",
+ "error.sampleNotFound": "Projet d’exemple introuvable",
+ "error.title": "Une erreur s’est produite",
+ "error.unsupportedFile": "Type de fichier non pris en charge : « {name} »",
+ "error.version": "Version de projet non prise en charge : {version}",
+ "export.download": "Télécharger le PNG",
+ "export.exporting": "Export en cours…",
+ "export.failed": "L’export a échoué, veuillez réessayer",
+ "export.fit": "Ajuster",
+ "export.generating": "Génération de l’aperçu…",
+ "export.hint": "Faites défiler pour zoomer · glissez pour déplacer",
+ "export.includeSidebar": "Inclure le panneau des annotations",
+ "export.loadingPreview": "Chargement de l’aperçu…",
+ "export.previewAlt": "Aperçu de l’export",
+ "export.resetZoom": "Réinitialiser le zoom",
+ "export.scaleNotice": "La carte est plus grande que ce que cet appareil peut exporter en taille réelle ; le PNG sera donc à {percent} % de la résolution d’origine.",
+ "export.sidebarWithComments": "Les commentaires sont affichés sur la carte ; désactivez le panneau pour éviter les doublons.",
+ "export.sidebarWithLabels": "Le panneau liste chaque lettre de repère avec son commentaire.",
+ "export.textSize": "Taille du texte",
+ "export.title": "Exporter",
+ "export.zoomIn": "Zoom avant",
+ "export.zoomOut": "Zoom arrière",
+ "gpx.adjustMap": "Ajuster sur la carte",
+ "gpx.applyCrop": "Appliquer le recadrage",
+ "gpx.confirm": "Confirmer",
+ "gpx.crop": "Recadrer",
+ "gpx.cropAria": "Recadrer la trace",
+ "gpx.cropError": "La sélection doit comporter au moins 2 points.",
+ "gpx.cropTitle": "Recadrer la trace",
+ "gpx.empty": "Aucune trace GPX chargée.",
+ "gpx.fast": "Rapide (vert)",
+ "gpx.highHr": "FC élevée (rouge vif)",
+ "gpx.hr": "FC",
+ "gpx.km": "{value} km",
+ "gpx.lineStyleAria": "Style de trait",
+ "gpx.lowHr": "FC basse (rouge foncé)",
+ "gpx.noTiming": "Ce GPX ne contient pas de données de temps : recadrez par distance",
+ "gpx.opacity": "Opacité",
+ "gpx.opacityShort": "Opac.",
+ "gpx.pace": "Allure",
+ "gpx.remove": "Retirer",
+ "gpx.removeAria": "Retirer la trace",
+ "gpx.slow": "Lent (rouge)",
+ "gpx.solid": "Plein",
+ "gpx.strokeWidth": "Épaisseur du trait",
+ "gpx.tipAdjust": "Passer à la carte et faire glisser les points d’ancrage",
+ "gpx.tipCrop": "Recadrer le début et la fin de la trace",
+ "gpx.tipRemove": "Retirer cette trace du projet",
+ "gpx.title": "Traces GPX",
+ "gpx.toastTapHint": "Touchez la trace pour ajouter des épingles. Appuyez longuement sur une épingle pour la supprimer.",
+ "gpx.trackColour": "Couleur de la trace {color}",
+ "gpx.upload": "Ajouter",
+ "gpx.width": "Largeur",
+ "language.label": "Langue",
+ "legal.binding": "La version anglaise de ce document fait foi. Les traductions sont fournies à titre indicatif.",
+ "legal.lastUpdated": "Dernière mise à jour : {date}",
+ "library.deleteAria": "Supprimer {name}",
+ "library.empty": "Aucun projet enregistré pour le moment. Importez une carte pour en commencer un.",
+ "library.noMap": "Pas encore de carte",
+ "library.open": "Ouvert",
+ "library.renameAria": "Renommer {name}",
+ "library.title": "Projets",
+ "library.updated": "{map} · Mis à jour le {date}",
+ "menu.file": "Fichier",
+ "menu.legal": "Mentions légales",
+ "menu.menu": "Menu",
+ "menu.more": "Plus d’actions",
+ "menu.privacy": "Politique de confidentialité",
+ "menu.terms": "Conditions d’utilisation",
+ "notes.centerHint": "Cliquez pour centrer sur la carte",
+ "notes.commentPlaceholder": "Ajouter un commentaire…",
+ "notes.comments": "Commentaires",
+ "notes.commentsHint": "Affiche les commentaires directement sur la carte. Faites glisser le bord droit d’un commentaire sélectionné pour changer sa largeur.",
+ "notes.deleteAria": "Supprimer l’annotation {label}",
+ "notes.displayAria": "Affichage des repères de la carte",
+ "notes.empty": "Aucune annotation pour le moment. Utilisez l’outil repère pour ajouter des commentaires sur la carte.",
+ "notes.heading": "Annotations",
+ "notes.labels": "Étiquettes",
+ "notes.labelsHint": "Affiche les lettres sur les repères de la carte",
+ "notes.removeHint": "Supprimer ce repère",
+ "notes.toggle": "Notes",
+ "notes.toggleAria": "Afficher ou masquer les annotations",
+ "project.untitled": "Projet sans titre",
+ "pwa.dismissUpdate": "Ignorer la notification de mise à jour",
+ "pwa.updateAvailable": "Une nouvelle version d’O-Analysis est disponible.",
+ "pwa.updateNow": "Mettre à jour",
+ "rotation.degrees": "{degrees} degrés",
+ "rotation.group": "Rotation de la carte",
+ "rotation.label": "Rotation",
+ "rotation.reset": "Réinitialiser la rotation",
+ "rotation.resetAria": "Réinitialiser la rotation à 0 degré",
+ "rotation.rotate": "Pivoter",
+ "save.failed": "Échec de l’enregistrement automatique",
+ "save.saved": "Enregistré à {time}",
+ "save.saving": "Enregistrement…",
+ "status.coords": "X : {x} Y : {y}",
+ "status.credit": "par Anthony Riley ❤️",
+ "status.fitAria": "Zoom {zoom} %. Ajuster la carte à l’écran",
+ "status.fitHint": "Ajuster la carte à l’écran",
+ "status.legal": "Mentions légales",
+ "status.noCoords": "X : — Y : —",
+ "status.privacy": "Confidentialité",
+ "status.terms": "Conditions",
+ "status.textSize": "Taille du texte",
+ "status.textSizeShort": "Texte",
+ "status.tool": "Outil : {tool}",
+ "status.zoomIn": "Zoom avant",
+ "status.zoomOut": "Zoom arrière",
+ "stroke.color": "Couleur",
+ "stroke.colorAria": "Couleur {color}",
+ "stroke.custom": "Couleur personnalisée",
+ "stroke.customHint": "Choisir une couleur personnalisée",
+ "stroke.opacity": "Opacité",
+ "stroke.opacityHint": "Transparence des traits et repères dessinés",
+ "stroke.useColor": "Utiliser {color}",
+ "stroke.width": "Largeur",
+ "stroke.widthHint": "Épaisseur du trait et du repère",
+ "style.commentTextSize": "Taille du texte des commentaires",
+ "tool.eraser": "Gomme",
+ "tool.eraser.tip": "Cliquez ou glissez sur les tracés et repères pour les supprimer.",
+ "tool.eraser.touchTip": "Touchez ou glissez sur les tracés et repères pour les supprimer.",
+ "tool.gpx": "Ajuster le GPX",
+ "tool.gpx.tip": "Cliquez sur la trace pour ajouter des épingles, glissez-les pour les déplacer, et faites un clic droit sur une épingle pour la supprimer.",
+ "tool.gpx.touchTip": "Touchez la trace pour ajouter des épingles. Appuyez longuement sur une épingle pour la supprimer.",
+ "tool.gpxNeedsFile": "Importez d’abord un fichier GPX",
+ "tool.gpxShort": "GPX",
+ "tool.hotkeyLabel": "{tip} ({key})",
+ "tool.line": "Tracer",
+ "tool.line.tip": "Cliquez et glissez pour tracer des lignes sur la carte.",
+ "tool.line.touchTip": "Faites glisser le doigt pour tracer des lignes sur la carte.",
+ "tool.marker": "Repère",
+ "tool.marker.tip": "Cliquez pour placer un repère numéroté avec un commentaire.",
+ "tool.marker.touchTip": "Touchez pour placer un repère numéroté avec un commentaire.",
+ "tool.pan": "Déplacer",
+ "tool.pan.tip": "Faites glisser la carte pour vous déplacer. Maintenez la barre d’espace ou le bouton central pour un déplacement temporaire.",
+ "tool.pan.touchTip": "Glissez avec un doigt, ou utilisez deux doigts pour déplacer la carte avec n’importe quel outil.",
+ "tool.select": "Sélectionner",
+ "tool.select.tip": "Cliquez sur des tracés ou des repères pour les sélectionner. Appuyez sur Suppr pour les retirer.",
+ "tool.select.touchTip": "Touchez des tracés ou des repères pour les sélectionner.",
+ "toolbar.annotations": "Annotations",
+ "toolbar.drawingTools": "Outils de dessin",
+ "toolbar.notes": "Notes",
+ "toolbar.strokeSettings": "Réglages du trait",
+ "toolbar.style": "Style",
+ "toolbar.viewAndStroke": "Affichage et réglages du trait",
+ "top.dismissError": "Ignorer l’erreur",
+ "top.editGpxHint": "Modifier la couleur, la largeur ou l’alignement des traces",
+ "top.exportDisabled": "Importez une image de carte avant d’exporter",
+ "top.exportHint": "Prévisualiser et télécharger la carte avec les tracés et annotations",
+ "top.history": "Historique",
+ "top.importGpxHint": "Importer un fichier GPX pour superposer un itinéraire sur la carte",
+ "top.importImageHint": "Charger une image de carte (PNG, JPG) ou déposer un fichier sur le canevas",
+ "top.installApp": "Installer l’application",
+ "top.installHint": "Installer O-Analysis comme application de bureau",
+ "top.new": "Nouveau",
+ "top.newHint": "Vider le canevas et recommencer",
+ "top.open": "Ouvrir",
+ "top.openHint": "Ouvrir un projet enregistré ({ext})",
+ "top.projectName": "Nom du projet",
+ "top.projects": "Projets",
+ "top.projectsHint": "Passer d’un projet enregistré à un autre",
+ "top.redo": "Rétablir",
+ "top.redoHint": "Rétablir (⌘⇧Z / Ctrl+Maj+Z)",
+ "top.replaceImageHint": "Remplacer l’image de carte actuelle",
+ "top.save": "Enregistrer",
+ "top.saveDisabled": "Rien à enregistrer pour le moment : importez une carte ou ajoutez des annotations",
+ "top.saveHint": "Enregistrer le projet au format {ext}",
+ "top.undo": "Annuler",
+ "top.undoHint": "Annuler la dernière action (⌘Z / Ctrl+Z)",
+ "top.updateApp": "Mettre à jour l’application",
+ "top.updateHint": "Recharger pour utiliser la dernière version",
+ "touch.dismissTips": "Ignorer les conseils",
+ "touch.tips": "Pincez pour zoomer, utilisez deux doigts pour déplacer la carte. Touchez un outil puis la carte pour tracer ou placer des repères."
+}
diff --git a/src/locales/nb.json b/src/locales/nb.json
new file mode 100644
index 0000000..96217a3
--- /dev/null
+++ b/src/locales/nb.json
@@ -0,0 +1,232 @@
+{
+ "action.editGpx": "Rediger GPX",
+ "action.editImage": "Rediger bilde",
+ "action.export": "Eksporter",
+ "action.importGpx": "Importer GPX",
+ "action.importImage": "Importer bilde",
+ "action.installApp": "Installer appen",
+ "action.newProject": "Nytt prosjekt",
+ "action.openProject": "Åpne prosjekt",
+ "action.projects": "Prosjekter",
+ "action.saveProject": "Lagre prosjekt",
+ "action.updateApp": "Oppdater appen",
+ "canvas.importHintDrop": "Importer et kartbilde for å komme i gang, eller slipp en fil her.",
+ "canvas.importHintTouch": "Importer et kartbilde for å komme i gang.",
+ "canvas.importMap": "Importer kart",
+ "canvas.loadingSample": "Laster eksempel…",
+ "canvas.noMap": "Ingen kart lastet",
+ "canvas.openSample": "Åpne eksempelprosjekt",
+ "color.channel": "{channel}-kanal",
+ "color.channelValue": "{channel}-verdi",
+ "color.custom": "Egendefinert farge",
+ "color.hex": "Hex",
+ "color.hexAria": "Hex-farge",
+ "common.apply": "Bruk",
+ "common.cancel": "Avbryt",
+ "common.close": "Lukk",
+ "common.delete": "Slett",
+ "common.dismiss": "Lukk",
+ "common.errorGeneric": "Noe gikk galt",
+ "common.loading": "Laster…",
+ "common.rename": "Gi nytt navn",
+ "error.body": "O-Analysis møtte en uventet feil. Å laste siden på nytt pleier å løse problemet.",
+ "error.damagedEntry": "Ugyldig {label} i prosjektfilen (oppføring {index} er skadet)",
+ "error.decodeImage": "Bildefilen kunne ikke leses",
+ "error.decodeMap": "Kartbildet kunne ikke leses",
+ "error.exportTooLarge": "Kartet er for stort til å eksporteres på denne enheten",
+ "error.gpxImport": "GPX-importen mislyktes",
+ "error.imageImport": "Bildeimporten mislyktes",
+ "error.imageTooLarge": "Bildet er for stort ({size}). Maksimal størrelse er 20 MB.",
+ "error.invalidEntry": "Ugyldig {label} i prosjektfilen",
+ "error.invalidMap": "Ugyldig kartbilde i prosjektfilen",
+ "error.invalidName": "Ugyldig prosjektnavn i prosjektfilen",
+ "error.invalidRotation": "Ugyldig rotasjon i prosjektfilen",
+ "error.labelAnnotations": "merknader",
+ "error.labelDrawings": "tegninger",
+ "error.labelTracks": "GPX-spor",
+ "error.loadSample": "Eksempelprosjektet kunne ikke lastes",
+ "error.noGpxPoints": "GPX-filen inneholder ingen brukbare sporpunkter",
+ "error.notGpx": "Ikke en gyldig GPX-fil",
+ "error.notImage": "«{name}» er ikke en bildefil",
+ "error.notJson": "Prosjektfilen er ikke gyldig JSON",
+ "error.notOurs": "Ikke en O-Analysis-prosjektfil",
+ "error.notProjectFile": "Ikke en prosjektfil – forventet {ext}",
+ "error.nothingToSave": "Ingenting å lagre – importer et kart eller legg til innhold først",
+ "error.openProject": "Prosjektfilen kunne ikke åpnes",
+ "error.projectNotFound": "Fant ikke prosjektet",
+ "error.readImage": "Bildefilen kunne ikke leses",
+ "error.reload": "Last siden på nytt",
+ "error.renderExport": "Eksportbildet kunne ikke tegnes",
+ "error.sampleNotFound": "Fant ikke eksempelprosjektet",
+ "error.title": "Noe gikk galt",
+ "error.unsupportedFile": "Filtypen støttes ikke: «{name}»",
+ "error.version": "Prosjektversjonen støttes ikke: {version}",
+ "export.download": "Last ned PNG",
+ "export.exporting": "Eksporterer…",
+ "export.failed": "Eksporten mislyktes – prøv igjen",
+ "export.fit": "Tilpass",
+ "export.generating": "Lager forhåndsvisning…",
+ "export.hint": "Rull for å zoome · dra for å flytte",
+ "export.includeSidebar": "Ta med merknadspanelet",
+ "export.loadingPreview": "Laster forhåndsvisning…",
+ "export.previewAlt": "Forhåndsvisning av eksport",
+ "export.resetZoom": "Tilbakestill zoom",
+ "export.scaleNotice": "Kartet er større enn det denne enheten kan eksportere i full størrelse, så PNG-filen blir {percent} % av opprinnelig oppløsning.",
+ "export.sidebarWithComments": "Kommentarene vises på kartet – slå av panelet for å unngå duplisering.",
+ "export.sidebarWithLabels": "Panelet lister opp hver markørbokstav med kommentaren.",
+ "export.textSize": "Tekststørrelse",
+ "export.title": "Eksporter",
+ "export.zoomIn": "Zoom inn",
+ "export.zoomOut": "Zoom ut",
+ "gpx.adjustMap": "Juster på kartet",
+ "gpx.applyCrop": "Bruk beskjæring",
+ "gpx.confirm": "Bekreft",
+ "gpx.crop": "Beskjær",
+ "gpx.cropAria": "Beskjær spor",
+ "gpx.cropError": "Utvalget må inneholde minst 2 punkter.",
+ "gpx.cropTitle": "Beskjær spor",
+ "gpx.empty": "Ingen GPX-spor lastet.",
+ "gpx.fast": "Raskt (grønn)",
+ "gpx.highHr": "Høy puls (lys rød)",
+ "gpx.hr": "Puls",
+ "gpx.km": "{value} km",
+ "gpx.lineStyleAria": "Linjestil",
+ "gpx.lowHr": "Lav puls (mørk rød)",
+ "gpx.noTiming": "Ingen tidsdata i denne GPX-filen – beskjær etter distanse",
+ "gpx.opacity": "Opasitet",
+ "gpx.opacityShort": "Opas.",
+ "gpx.pace": "Tempo",
+ "gpx.remove": "Fjern",
+ "gpx.removeAria": "Fjern spor",
+ "gpx.slow": "Sakte (rød)",
+ "gpx.solid": "Heltrukket",
+ "gpx.strokeWidth": "Linjebredde",
+ "gpx.tipAdjust": "Bytt til kartet og dra ankerpunktene",
+ "gpx.tipCrop": "Beskjær starten og slutten av sporet",
+ "gpx.tipRemove": "Fjern dette sporet fra prosjektet",
+ "gpx.title": "GPX-spor",
+ "gpx.toastTapHint": "Trykk på sporet for å legge til punkter. Trykk lenge på et punkt for å slette det.",
+ "gpx.trackColour": "Sporfarge {color}",
+ "gpx.upload": "Last opp",
+ "gpx.width": "Bredde",
+ "language.label": "Språk",
+ "legal.binding": "Den engelske versjonen av dokumentet er den bindende teksten. Oversettelsene er laget for din bekvemmelighet.",
+ "legal.lastUpdated": "Sist oppdatert: {date}",
+ "library.deleteAria": "Slett {name}",
+ "library.empty": "Ingen lagrede prosjekter ennå. Importer et kart for å starte ett.",
+ "library.noMap": "Ingen kart ennå",
+ "library.open": "Åpne",
+ "library.renameAria": "Gi nytt navn til {name}",
+ "library.title": "Prosjekter",
+ "library.updated": "{map} · Oppdatert {date}",
+ "menu.file": "Fil",
+ "menu.legal": "Juridisk",
+ "menu.menu": "Meny",
+ "menu.more": "Flere handlinger",
+ "menu.privacy": "Personvernerklæring",
+ "menu.terms": "Vilkår for bruk",
+ "notes.centerHint": "Klikk for å sentrere på kartet",
+ "notes.commentPlaceholder": "Legg til en kommentar…",
+ "notes.comments": "Kommentarer",
+ "notes.commentsHint": "Vis kommentarer direkte på kartet. Dra i høyre kant på en valgt kommentar for å endre bredden.",
+ "notes.deleteAria": "Slett merknad {label}",
+ "notes.displayAria": "Visning av kartmarkører",
+ "notes.empty": "Ingen merknader ennå. Bruk markørverktøyet for å legge til kommentarer på kartet.",
+ "notes.heading": "Merknader",
+ "notes.labels": "Etiketter",
+ "notes.labelsHint": "Vis bokstavetiketter på kartmarkørene",
+ "notes.removeHint": "Fjern denne markøren",
+ "notes.toggle": "Notater",
+ "notes.toggleAria": "Vis eller skjul merknader",
+ "project.untitled": "Uten navn",
+ "pwa.dismissUpdate": "Lukk oppdateringsvarselet",
+ "pwa.updateAvailable": "En ny versjon av O-Analysis er tilgjengelig.",
+ "pwa.updateNow": "Oppdater nå",
+ "rotation.degrees": "{degrees} grader",
+ "rotation.group": "Kartrotasjon",
+ "rotation.label": "Rotasjon",
+ "rotation.reset": "Tilbakestill rotasjon",
+ "rotation.resetAria": "Tilbakestill rotasjonen til 0 grader",
+ "rotation.rotate": "Roter",
+ "save.failed": "Automatisk lagring mislyktes",
+ "save.saved": "Lagret {time}",
+ "save.saving": "Lagrer…",
+ "status.coords": "X: {x} Y: {y}",
+ "status.credit": "av Anthony Riley ❤️",
+ "status.fitAria": "Zoom {zoom} %. Tilpass kartet til skjermen",
+ "status.fitHint": "Tilpass kartet til skjermen",
+ "status.legal": "Juridisk",
+ "status.noCoords": "X: — Y: —",
+ "status.privacy": "Personvern",
+ "status.terms": "Vilkår",
+ "status.textSize": "Tekststørrelse",
+ "status.textSizeShort": "Tekst",
+ "status.tool": "Verktøy: {tool}",
+ "status.zoomIn": "Zoom inn",
+ "status.zoomOut": "Zoom ut",
+ "stroke.color": "Farge",
+ "stroke.colorAria": "Farge {color}",
+ "stroke.custom": "Egendefinert farge",
+ "stroke.customHint": "Velg en egendefinert farge",
+ "stroke.opacity": "Opasitet",
+ "stroke.opacityHint": "Gjennomsiktighet for tegnede linjer og markører",
+ "stroke.useColor": "Bruk {color}",
+ "stroke.width": "Bredde",
+ "stroke.widthHint": "Linje- og markørbredde",
+ "style.commentTextSize": "Tekststørrelse for kommentarer",
+ "tool.eraser": "Viskelær",
+ "tool.eraser.tip": "Klikk eller dra over linjer og markører for å fjerne dem.",
+ "tool.eraser.touchTip": "Trykk eller dra over linjer og markører for å fjerne dem.",
+ "tool.gpx": "Juster GPX",
+ "tool.gpx.tip": "Klikk på sporet for å legge til punkter, dra for å flytte dem, og høyreklikk et punkt for å slette det.",
+ "tool.gpx.touchTip": "Trykk på sporet for å legge til punkter. Trykk lenge på et punkt for å slette det.",
+ "tool.gpxNeedsFile": "Importer en GPX-fil først",
+ "tool.gpxShort": "GPX",
+ "tool.hotkeyLabel": "{tip} ({key})",
+ "tool.line": "Tegn",
+ "tool.line.tip": "Klikk og dra for å tegne linjer på kartet.",
+ "tool.line.touchTip": "Dra fingeren for å tegne linjer på kartet.",
+ "tool.marker": "Markør",
+ "tool.marker.tip": "Klikk for å plassere en nummerert markør med en kommentar.",
+ "tool.marker.touchTip": "Trykk for å plassere en nummerert markør med en kommentar.",
+ "tool.pan": "Flytt",
+ "tool.pan.tip": "Dra kartet for å bevege deg rundt. Hold mellomrom eller midtknappen for midlertidig flytting.",
+ "tool.pan.touchTip": "Dra med ett finger, eller bruk to fingre for å flytte i alle verktøy.",
+ "tool.select": "Velg",
+ "tool.select.tip": "Klikk på linjer eller markører for å velge dem. Trykk Delete for å fjerne.",
+ "tool.select.touchTip": "Trykk på linjer eller markører for å velge dem.",
+ "toolbar.annotations": "Merknader",
+ "toolbar.drawingTools": "Tegneverktøy",
+ "toolbar.notes": "Notater",
+ "toolbar.strokeSettings": "Linjeinnstillinger",
+ "toolbar.style": "Stil",
+ "toolbar.viewAndStroke": "Visning og linjeinnstillinger",
+ "top.dismissError": "Lukk feilmeldingen",
+ "top.editGpxHint": "Endre sporfarge, bredde eller justering",
+ "top.exportDisabled": "Importer et kartbilde før du eksporterer",
+ "top.exportHint": "Forhåndsvis og last ned kartet med tegninger og merknader",
+ "top.history": "Historikk",
+ "top.importGpxHint": "Importer en GPX-fil for å legge en rute over kartet",
+ "top.importImageHint": "Last opp et kartbilde – PNG, JPG, eller slipp en fil på lerretet",
+ "top.installApp": "Installer appen",
+ "top.installHint": "Installer O-Analysis som et skrivebordsprogram",
+ "top.new": "Ny",
+ "top.newHint": "Tøm lerretet og start på nytt",
+ "top.open": "Åpne",
+ "top.openHint": "Åpne et lagret prosjekt ({ext})",
+ "top.projectName": "Prosjektnavn",
+ "top.projects": "Prosjekter",
+ "top.projectsHint": "Bytt mellom lagrede prosjekter",
+ "top.redo": "Gjør om",
+ "top.redoHint": "Gjør om (⌘⇧Z / Ctrl+Shift+Z)",
+ "top.replaceImageHint": "Erstatt det gjeldende kartbildet",
+ "top.save": "Lagre",
+ "top.saveDisabled": "Ingenting å lagre ennå – importer et kart eller legg til merknader først",
+ "top.saveHint": "Lagre prosjektet som {ext}",
+ "top.undo": "Angre",
+ "top.undoHint": "Angre siste handling (⌘Z / Ctrl+Z)",
+ "top.updateApp": "Oppdater appen",
+ "top.updateHint": "Last inn på nytt for å bruke den nyeste versjonen",
+ "touch.dismissTips": "Lukk tipsene",
+ "touch.tips": "Klyp for å zoome, bruk to fingre for å flytte. Trykk på et verktøy og deretter på kartet for å tegne eller plassere markører."
+}
diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json
new file mode 100644
index 0000000..1287d20
--- /dev/null
+++ b/src/locales/pt-BR.json
@@ -0,0 +1,232 @@
+{
+ "action.editGpx": "Editar GPX",
+ "action.editImage": "Editar imagem",
+ "action.export": "Exportar",
+ "action.importGpx": "Importar GPX",
+ "action.importImage": "Importar imagem",
+ "action.installApp": "Instalar o app",
+ "action.newProject": "Novo projeto",
+ "action.openProject": "Abrir projeto",
+ "action.projects": "Projetos",
+ "action.saveProject": "Salvar projeto",
+ "action.updateApp": "Atualizar o app",
+ "canvas.importHintDrop": "Importe uma imagem de mapa para começar, ou solte um arquivo aqui.",
+ "canvas.importHintTouch": "Importe uma imagem de mapa para começar.",
+ "canvas.importMap": "Importar mapa",
+ "canvas.loadingSample": "Carregando exemplo…",
+ "canvas.noMap": "Nenhum mapa carregado",
+ "canvas.openSample": "Abrir projeto de exemplo",
+ "color.channel": "Canal {channel}",
+ "color.channelValue": "Valor de {channel}",
+ "color.custom": "Cor personalizada",
+ "color.hex": "Hex",
+ "color.hexAria": "Cor hexadecimal",
+ "common.apply": "Aplicar",
+ "common.cancel": "Cancelar",
+ "common.close": "Fechar",
+ "common.delete": "Excluir",
+ "common.dismiss": "Dispensar",
+ "common.errorGeneric": "Algo deu errado",
+ "common.loading": "Carregando…",
+ "common.rename": "Renomear",
+ "error.body": "O O-Analysis encontrou um erro inesperado. Recarregar a página geralmente resolve.",
+ "error.damagedEntry": "{label} inválido no arquivo do projeto (o item {index} está danificado)",
+ "error.decodeImage": "Não foi possível ler o arquivo de imagem",
+ "error.decodeMap": "Não foi possível ler a imagem do mapa",
+ "error.exportTooLarge": "O mapa é grande demais para exportar neste dispositivo",
+ "error.gpxImport": "Falha ao importar o GPX",
+ "error.imageImport": "Falha ao importar a imagem",
+ "error.imageTooLarge": "A imagem é grande demais ({size}). O tamanho máximo é 20 MB.",
+ "error.invalidEntry": "{label} inválido no arquivo do projeto",
+ "error.invalidMap": "Imagem de mapa inválida no arquivo do projeto",
+ "error.invalidName": "Nome de projeto inválido no arquivo do projeto",
+ "error.invalidRotation": "Rotação inválida no arquivo do projeto",
+ "error.labelAnnotations": "anotações",
+ "error.labelDrawings": "desenhos",
+ "error.labelTracks": "trajetos GPX",
+ "error.loadSample": "Falha ao carregar o projeto de exemplo",
+ "error.noGpxPoints": "O arquivo GPX não contém pontos de trajeto utilizáveis",
+ "error.notGpx": "Não é um arquivo GPX válido",
+ "error.notImage": "“{name}” não é um arquivo de imagem",
+ "error.notJson": "O arquivo do projeto não é um JSON válido",
+ "error.notOurs": "Não é um arquivo de projeto do O-Analysis",
+ "error.notProjectFile": "Não é um arquivo de projeto; esperado {ext}",
+ "error.nothingToSave": "Nada para salvar – importe um mapa ou adicione conteúdo primeiro",
+ "error.openProject": "Não foi possível abrir o arquivo do projeto",
+ "error.projectNotFound": "Não foi possível encontrar o projeto",
+ "error.readImage": "Não foi possível ler o arquivo de imagem",
+ "error.reload": "Recarregar página",
+ "error.renderExport": "Não foi possível gerar a imagem da exportação",
+ "error.sampleNotFound": "Projeto de exemplo não encontrado",
+ "error.title": "Algo deu errado",
+ "error.unsupportedFile": "Tipo de arquivo não suportado: “{name}”",
+ "error.version": "Versão de projeto não suportada: {version}",
+ "export.download": "Baixar PNG",
+ "export.exporting": "Exportando…",
+ "export.failed": "A exportação falhou – tente novamente",
+ "export.fit": "Ajustar",
+ "export.generating": "Gerando pré-visualização…",
+ "export.hint": "Role para dar zoom · arraste para mover",
+ "export.includeSidebar": "Incluir painel de anotações",
+ "export.loadingPreview": "Carregando pré-visualização…",
+ "export.previewAlt": "Pré-visualização da exportação",
+ "export.resetZoom": "Redefinir zoom",
+ "export.scaleNotice": "O mapa é maior do que este dispositivo consegue exportar em tamanho real, então o PNG ficará com {percent} % da resolução original.",
+ "export.sidebarWithComments": "Os comentários aparecem no mapa – desative o painel para não duplicar.",
+ "export.sidebarWithLabels": "O painel lista cada letra de marcador com o seu comentário.",
+ "export.textSize": "Tamanho do texto",
+ "export.title": "Exportar",
+ "export.zoomIn": "Aproximar",
+ "export.zoomOut": "Afastar",
+ "gpx.adjustMap": "Ajustar no mapa",
+ "gpx.applyCrop": "Aplicar recorte",
+ "gpx.confirm": "Confirmar",
+ "gpx.crop": "Recortar",
+ "gpx.cropAria": "Recortar trajeto",
+ "gpx.cropError": "A seleção deve incluir pelo menos 2 pontos.",
+ "gpx.cropTitle": "Recortar trajeto",
+ "gpx.empty": "Nenhum trajeto GPX carregado.",
+ "gpx.fast": "Rápido (verde)",
+ "gpx.highHr": "FC alta (vermelho vivo)",
+ "gpx.hr": "FC",
+ "gpx.km": "{value} km",
+ "gpx.lineStyleAria": "Estilo de linha",
+ "gpx.lowHr": "FC baixa (vermelho escuro)",
+ "gpx.noTiming": "Este GPX não tem dados de tempo – recorte por distância",
+ "gpx.opacity": "Opacidade",
+ "gpx.opacityShort": "Opac.",
+ "gpx.pace": "Ritmo",
+ "gpx.remove": "Remover",
+ "gpx.removeAria": "Remover trajeto",
+ "gpx.slow": "Lento (vermelho)",
+ "gpx.solid": "Sólido",
+ "gpx.strokeWidth": "Espessura da linha",
+ "gpx.tipAdjust": "Mude para o mapa e arraste os pinos de ancoragem",
+ "gpx.tipCrop": "Recorte o início e o fim do trajeto",
+ "gpx.tipRemove": "Remover este trajeto do projeto",
+ "gpx.title": "Trajetos GPX",
+ "gpx.toastTapHint": "Toque no trajeto para adicionar pinos. Pressione e segure um pino para excluí-lo.",
+ "gpx.trackColour": "Cor do trajeto {color}",
+ "gpx.upload": "Enviar",
+ "gpx.width": "Largura",
+ "language.label": "Idioma",
+ "legal.binding": "A versão em inglês deste documento é o texto vinculante. As traduções são oferecidas apenas para sua conveniência.",
+ "legal.lastUpdated": "Última atualização: {date}",
+ "library.deleteAria": "Excluir {name}",
+ "library.empty": "Nenhum projeto salvo ainda. Importe um mapa para começar um.",
+ "library.noMap": "Sem mapa ainda",
+ "library.open": "Aberto",
+ "library.renameAria": "Renomear {name}",
+ "library.title": "Projetos",
+ "library.updated": "{map} · Atualizado em {date}",
+ "menu.file": "Arquivo",
+ "menu.legal": "Jurídico",
+ "menu.menu": "Menu",
+ "menu.more": "Mais ações",
+ "menu.privacy": "Política de privacidade",
+ "menu.terms": "Termos de uso",
+ "notes.centerHint": "Clique para centralizar no mapa",
+ "notes.commentPlaceholder": "Adicione um comentário…",
+ "notes.comments": "Comentários",
+ "notes.commentsHint": "Mostra comentários diretamente no mapa. Arraste a borda direita de um comentário selecionado para mudar a largura.",
+ "notes.deleteAria": "Excluir anotação {label}",
+ "notes.displayAria": "Exibição dos marcadores do mapa",
+ "notes.empty": "Nenhuma anotação ainda. Use a ferramenta de marcador para adicionar comentários ao mapa.",
+ "notes.heading": "Anotações",
+ "notes.labels": "Rótulos",
+ "notes.labelsHint": "Mostra letras nos marcadores do mapa",
+ "notes.removeHint": "Remover este marcador",
+ "notes.toggle": "Notas",
+ "notes.toggleAria": "Mostrar ou ocultar anotações",
+ "project.untitled": "Projeto sem título",
+ "pwa.dismissUpdate": "Dispensar aviso de atualização",
+ "pwa.updateAvailable": "Uma nova versão do O-Analysis está disponível.",
+ "pwa.updateNow": "Atualizar agora",
+ "rotation.degrees": "{degrees} graus",
+ "rotation.group": "Rotação da tela",
+ "rotation.label": "Rotação",
+ "rotation.reset": "Redefinir rotação",
+ "rotation.resetAria": "Redefinir a rotação para 0 graus",
+ "rotation.rotate": "Girar",
+ "save.failed": "Falha no salvamento automático",
+ "save.saved": "Salvo às {time}",
+ "save.saving": "Salvando…",
+ "status.coords": "X: {x} Y: {y}",
+ "status.credit": "por Anthony Riley ❤️",
+ "status.fitAria": "Zoom {zoom} %. Ajustar o mapa à tela",
+ "status.fitHint": "Ajustar o mapa à tela",
+ "status.legal": "Jurídico",
+ "status.noCoords": "X: — Y: —",
+ "status.privacy": "Privacidade",
+ "status.terms": "Termos",
+ "status.textSize": "Tamanho do texto",
+ "status.textSizeShort": "Texto",
+ "status.tool": "Ferramenta: {tool}",
+ "status.zoomIn": "Aproximar",
+ "status.zoomOut": "Afastar",
+ "stroke.color": "Cor",
+ "stroke.colorAria": "Cor {color}",
+ "stroke.custom": "Cor personalizada",
+ "stroke.customHint": "Escolher uma cor personalizada",
+ "stroke.opacity": "Opacidade",
+ "stroke.opacityHint": "Transparência das linhas e marcadores desenhados",
+ "stroke.useColor": "Usar {color}",
+ "stroke.width": "Largura",
+ "stroke.widthHint": "Espessura da linha e do marcador",
+ "style.commentTextSize": "Tamanho do texto dos comentários",
+ "tool.eraser": "Borracha",
+ "tool.eraser.tip": "Clique ou arraste sobre trajetos e marcadores para removê-los.",
+ "tool.eraser.touchTip": "Toque ou arraste sobre trajetos e marcadores para removê-los.",
+ "tool.gpx": "Ajustar GPX",
+ "tool.gpx.tip": "Clique no trajeto para adicionar pinos, arraste-os para mover e clique com o botão direito em um pino para excluí-lo.",
+ "tool.gpx.touchTip": "Toque no trajeto para adicionar pinos. Pressione e segure um pino para excluí-lo.",
+ "tool.gpxNeedsFile": "Importe um arquivo GPX primeiro",
+ "tool.gpxShort": "GPX",
+ "tool.hotkeyLabel": "{tip} ({key})",
+ "tool.line": "Desenhar",
+ "tool.line.tip": "Clique e arraste para desenhar linhas no mapa.",
+ "tool.line.touchTip": "Arraste o dedo para desenhar linhas no mapa.",
+ "tool.marker": "Marcador",
+ "tool.marker.tip": "Clique para colocar um marcador numerado com um comentário.",
+ "tool.marker.touchTip": "Toque para colocar um marcador numerado com um comentário.",
+ "tool.pan": "Mover",
+ "tool.pan.tip": "Arraste o mapa para se movimentar. Segure a barra de espaço ou o botão do meio para mover temporariamente.",
+ "tool.pan.touchTip": "Arraste com um dedo, ou use dois dedos para mover o mapa em qualquer ferramenta.",
+ "tool.select": "Selecionar",
+ "tool.select.tip": "Clique em trajetos ou marcadores para selecioná-los. Pressione Delete para remover.",
+ "tool.select.touchTip": "Toque em trajetos ou marcadores para selecioná-los.",
+ "toolbar.annotations": "Anotações",
+ "toolbar.drawingTools": "Ferramentas de desenho",
+ "toolbar.notes": "Notas",
+ "toolbar.strokeSettings": "Configurações de traço",
+ "toolbar.style": "Estilo",
+ "toolbar.viewAndStroke": "Visualização e configurações de traço",
+ "top.dismissError": "Dispensar erro",
+ "top.editGpxHint": "Mude a cor, a largura ou o alinhamento dos trajetos",
+ "top.exportDisabled": "Importe uma imagem de mapa antes de exportar",
+ "top.exportHint": "Pré-visualize e baixe o mapa com desenhos e anotações",
+ "top.history": "Histórico",
+ "top.importGpxHint": "Importe um arquivo GPX para sobrepor uma rota no mapa",
+ "top.importImageHint": "Envie uma imagem de mapa (PNG, JPG) ou solte um arquivo na tela",
+ "top.installApp": "Instalar o app",
+ "top.installHint": "Instale o O-Analysis como aplicativo de desktop",
+ "top.new": "Novo",
+ "top.newHint": "Limpe a tela e comece do zero",
+ "top.open": "Abrir",
+ "top.openHint": "Abra um projeto salvo ({ext})",
+ "top.projectName": "Nome do projeto",
+ "top.projects": "Projetos",
+ "top.projectsHint": "Alterne entre projetos salvos",
+ "top.redo": "Refazer",
+ "top.redoHint": "Refazer (⌘⇧Z / Ctrl+Shift+Z)",
+ "top.replaceImageHint": "Substituir a imagem de mapa atual",
+ "top.save": "Salvar",
+ "top.saveDisabled": "Nada para salvar ainda – importe um mapa ou adicione anotações primeiro",
+ "top.saveHint": "Salvar o projeto como {ext}",
+ "top.undo": "Desfazer",
+ "top.undoHint": "Desfazer a última ação (⌘Z / Ctrl+Z)",
+ "top.updateApp": "Atualizar o app",
+ "top.updateHint": "Recarregue para usar a versão mais recente",
+ "touch.dismissTips": "Dispensar as dicas",
+ "touch.tips": "Pince para dar zoom, use dois dedos para mover. Toque em uma ferramenta e depois no mapa para desenhar ou colocar marcadores."
+}
diff --git a/src/locales/pt-PT.json b/src/locales/pt-PT.json
new file mode 100644
index 0000000..5c6a144
--- /dev/null
+++ b/src/locales/pt-PT.json
@@ -0,0 +1,232 @@
+{
+ "action.editGpx": "Editar GPX",
+ "action.editImage": "Editar imagem",
+ "action.export": "Exportar",
+ "action.importGpx": "Importar GPX",
+ "action.importImage": "Importar imagem",
+ "action.installApp": "Instalar a aplicação",
+ "action.newProject": "Novo projeto",
+ "action.openProject": "Abrir projeto",
+ "action.projects": "Projetos",
+ "action.saveProject": "Guardar projeto",
+ "action.updateApp": "Atualizar a aplicação",
+ "canvas.importHintDrop": "Importe uma imagem de mapa para começar, ou arraste um ficheiro para aqui.",
+ "canvas.importHintTouch": "Importe uma imagem de mapa para começar.",
+ "canvas.importMap": "Importar mapa",
+ "canvas.loadingSample": "A carregar o exemplo…",
+ "canvas.noMap": "Nenhum mapa carregado",
+ "canvas.openSample": "Abrir projeto de exemplo",
+ "color.channel": "Canal {channel}",
+ "color.channelValue": "Valor de {channel}",
+ "color.custom": "Cor personalizada",
+ "color.hex": "Hex",
+ "color.hexAria": "Cor hexadecimal",
+ "common.apply": "Aplicar",
+ "common.cancel": "Cancelar",
+ "common.close": "Fechar",
+ "common.delete": "Eliminar",
+ "common.dismiss": "Dispensar",
+ "common.errorGeneric": "Algo correu mal",
+ "common.loading": "A carregar…",
+ "common.rename": "Mudar o nome",
+ "error.body": "O O-Analysis encontrou um erro inesperado. Recarregar a página costuma resolver.",
+ "error.damagedEntry": "{label} inválido no ficheiro do projeto (o item {index} está danificado)",
+ "error.decodeImage": "Não foi possível ler o ficheiro de imagem",
+ "error.decodeMap": "Não foi possível ler a imagem do mapa",
+ "error.exportTooLarge": "O mapa é demasiado grande para exportar neste dispositivo",
+ "error.gpxImport": "Falha ao importar o GPX",
+ "error.imageImport": "Falha ao importar a imagem",
+ "error.imageTooLarge": "A imagem é demasiado grande ({size}). O tamanho máximo é 20 MB.",
+ "error.invalidEntry": "{label} inválido no ficheiro do projeto",
+ "error.invalidMap": "Imagem de mapa inválida no ficheiro do projeto",
+ "error.invalidName": "Nome de projeto inválido no ficheiro do projeto",
+ "error.invalidRotation": "Rotação inválida no ficheiro do projeto",
+ "error.labelAnnotations": "anotações",
+ "error.labelDrawings": "desenhos",
+ "error.labelTracks": "percursos GPX",
+ "error.loadSample": "Falha ao carregar o projeto de exemplo",
+ "error.noGpxPoints": "O ficheiro GPX não contém pontos de percurso utilizáveis",
+ "error.notGpx": "Não é um ficheiro GPX válido",
+ "error.notImage": "«{name}» não é um ficheiro de imagem",
+ "error.notJson": "O ficheiro do projeto não é um JSON válido",
+ "error.notOurs": "Não é um ficheiro de projeto do O-Analysis",
+ "error.notProjectFile": "Não é um ficheiro de projeto; esperado {ext}",
+ "error.nothingToSave": "Nada para guardar – importe um mapa ou adicione conteúdo primeiro",
+ "error.openProject": "Não foi possível abrir o ficheiro do projeto",
+ "error.projectNotFound": "Não foi possível encontrar o projeto",
+ "error.readImage": "Não foi possível ler o ficheiro de imagem",
+ "error.reload": "Recarregar página",
+ "error.renderExport": "Não foi possível gerar a imagem da exportação",
+ "error.sampleNotFound": "Projeto de exemplo não encontrado",
+ "error.title": "Algo correu mal",
+ "error.unsupportedFile": "Tipo de ficheiro não suportado: «{name}»",
+ "error.version": "Versão de projeto não suportada: {version}",
+ "export.download": "Transferir PNG",
+ "export.exporting": "A exportar…",
+ "export.failed": "A exportação falhou – tente novamente",
+ "export.fit": "Ajustar",
+ "export.generating": "A gerar pré-visualização…",
+ "export.hint": "Deslize para ampliar · arraste para mover",
+ "export.includeSidebar": "Incluir painel de anotações",
+ "export.loadingPreview": "A carregar pré-visualização…",
+ "export.previewAlt": "Pré-visualização da exportação",
+ "export.resetZoom": "Repor zoom",
+ "export.scaleNotice": "O mapa é maior do que este dispositivo consegue exportar em tamanho real, por isso o PNG ficará com {percent} % da resolução original.",
+ "export.sidebarWithComments": "Os comentários aparecem no mapa – desative o painel para não duplicar.",
+ "export.sidebarWithLabels": "O painel lista cada letra de marcador com o respetivo comentário.",
+ "export.textSize": "Tamanho do texto",
+ "export.title": "Exportar",
+ "export.zoomIn": "Ampliar",
+ "export.zoomOut": "Reduzir",
+ "gpx.adjustMap": "Ajustar no mapa",
+ "gpx.applyCrop": "Aplicar recorte",
+ "gpx.confirm": "Confirmar",
+ "gpx.crop": "Recortar",
+ "gpx.cropAria": "Recortar percurso",
+ "gpx.cropError": "A seleção deve incluir pelo menos 2 pontos.",
+ "gpx.cropTitle": "Recortar percurso",
+ "gpx.empty": "Nenhum percurso GPX carregado.",
+ "gpx.fast": "Rápido (verde)",
+ "gpx.highHr": "FC alta (vermelho vivo)",
+ "gpx.hr": "FC",
+ "gpx.km": "{value} km",
+ "gpx.lineStyleAria": "Estilo de linha",
+ "gpx.lowHr": "FC baixa (vermelho escuro)",
+ "gpx.noTiming": "Este GPX não tem dados de tempo – recorte por distância",
+ "gpx.opacity": "Opacidade",
+ "gpx.opacityShort": "Opac.",
+ "gpx.pace": "Ritmo",
+ "gpx.remove": "Remover",
+ "gpx.removeAria": "Remover percurso",
+ "gpx.slow": "Lento (vermelho)",
+ "gpx.solid": "Contínuo",
+ "gpx.strokeWidth": "Espessura da linha",
+ "gpx.tipAdjust": "Mude para o mapa e arraste os alfinetes de ancoragem",
+ "gpx.tipCrop": "Recorte o início e o fim do percurso",
+ "gpx.tipRemove": "Remover este percurso do projeto",
+ "gpx.title": "Percursos GPX",
+ "gpx.toastTapHint": "Toque no percurso para adicionar alfinetes. Mantenha premido um alfinete para o eliminar.",
+ "gpx.trackColour": "Cor do percurso {color}",
+ "gpx.upload": "Carregar",
+ "gpx.width": "Largura",
+ "language.label": "Idioma",
+ "legal.binding": "A versão inglesa deste documento é o texto vinculativo. As traduções são disponibilizadas apenas para conveniência.",
+ "legal.lastUpdated": "Última atualização: {date}",
+ "library.deleteAria": "Eliminar {name}",
+ "library.empty": "Ainda não há projetos guardados. Importe um mapa para começar um.",
+ "library.noMap": "Sem mapa ainda",
+ "library.open": "Aberto",
+ "library.renameAria": "Mudar o nome de {name}",
+ "library.title": "Projetos",
+ "library.updated": "{map} · Atualizado em {date}",
+ "menu.file": "Ficheiro",
+ "menu.legal": "Jurídico",
+ "menu.menu": "Menu",
+ "menu.more": "Mais ações",
+ "menu.privacy": "Política de privacidade",
+ "menu.terms": "Termos de utilização",
+ "notes.centerHint": "Clique para centrar no mapa",
+ "notes.commentPlaceholder": "Adicione um comentário…",
+ "notes.comments": "Comentários",
+ "notes.commentsHint": "Mostra os comentários diretamente no mapa. Arraste a borda direita de um comentário selecionado para alterar a largura.",
+ "notes.deleteAria": "Eliminar anotação {label}",
+ "notes.displayAria": "Visualização dos marcadores do mapa",
+ "notes.empty": "Ainda não há anotações. Use a ferramenta de marcador para adicionar comentários ao mapa.",
+ "notes.heading": "Anotações",
+ "notes.labels": "Etiquetas",
+ "notes.labelsHint": "Mostra letras nos marcadores do mapa",
+ "notes.removeHint": "Remover este marcador",
+ "notes.toggle": "Notas",
+ "notes.toggleAria": "Mostrar ou ocultar anotações",
+ "project.untitled": "Projeto sem título",
+ "pwa.dismissUpdate": "Dispensar aviso de atualização",
+ "pwa.updateAvailable": "Está disponível uma nova versão do O-Analysis.",
+ "pwa.updateNow": "Atualizar agora",
+ "rotation.degrees": "{degrees} graus",
+ "rotation.group": "Rotação do ecrã",
+ "rotation.label": "Rotação",
+ "rotation.reset": "Repor rotação",
+ "rotation.resetAria": "Repor a rotação para 0 graus",
+ "rotation.rotate": "Rodar",
+ "save.failed": "Falha na gravação automática",
+ "save.saved": "Guardado às {time}",
+ "save.saving": "A guardar…",
+ "status.coords": "X: {x} Y: {y}",
+ "status.credit": "por Anthony Riley ❤️",
+ "status.fitAria": "Zoom {zoom} %. Ajustar o mapa ao ecrã",
+ "status.fitHint": "Ajustar o mapa ao ecrã",
+ "status.legal": "Jurídico",
+ "status.noCoords": "X: — Y: —",
+ "status.privacy": "Privacidade",
+ "status.terms": "Termos",
+ "status.textSize": "Tamanho do texto",
+ "status.textSizeShort": "Texto",
+ "status.tool": "Ferramenta: {tool}",
+ "status.zoomIn": "Ampliar",
+ "status.zoomOut": "Reduzir",
+ "stroke.color": "Cor",
+ "stroke.colorAria": "Cor {color}",
+ "stroke.custom": "Cor personalizada",
+ "stroke.customHint": "Escolher uma cor personalizada",
+ "stroke.opacity": "Opacidade",
+ "stroke.opacityHint": "Transparência das linhas e marcadores desenhados",
+ "stroke.useColor": "Usar {color}",
+ "stroke.width": "Largura",
+ "stroke.widthHint": "Espessura da linha e do marcador",
+ "style.commentTextSize": "Tamanho do texto dos comentários",
+ "tool.eraser": "Borracha",
+ "tool.eraser.tip": "Clique ou arraste sobre percursos e marcadores para os remover.",
+ "tool.eraser.touchTip": "Toque ou arraste sobre percursos e marcadores para os remover.",
+ "tool.gpx": "Ajustar GPX",
+ "tool.gpx.tip": "Clique no percurso para adicionar alfinetes, arraste-os para os mover e clique com o botão direito num alfinete para o eliminar.",
+ "tool.gpx.touchTip": "Toque no percurso para adicionar alfinetes. Mantenha premido um alfinete para o eliminar.",
+ "tool.gpxNeedsFile": "Importe primeiro um ficheiro GPX",
+ "tool.gpxShort": "GPX",
+ "tool.hotkeyLabel": "{tip} ({key})",
+ "tool.line": "Desenhar",
+ "tool.line.tip": "Clique e arraste para desenhar linhas no mapa.",
+ "tool.line.touchTip": "Arraste o dedo para desenhar linhas no mapa.",
+ "tool.marker": "Marcador",
+ "tool.marker.tip": "Clique para colocar um marcador numerado com um comentário.",
+ "tool.marker.touchTip": "Toque para colocar um marcador numerado com um comentário.",
+ "tool.pan": "Mover",
+ "tool.pan.tip": "Arraste o mapa para se deslocar. Mantenha premida a barra de espaço ou o botão do meio para mover temporariamente.",
+ "tool.pan.touchTip": "Arraste com um dedo, ou use dois dedos para mover o mapa em qualquer ferramenta.",
+ "tool.select": "Selecionar",
+ "tool.select.tip": "Clique em percursos ou marcadores para os selecionar. Prima Delete para remover.",
+ "tool.select.touchTip": "Toque em percursos ou marcadores para os selecionar.",
+ "toolbar.annotations": "Anotações",
+ "toolbar.drawingTools": "Ferramentas de desenho",
+ "toolbar.notes": "Notas",
+ "toolbar.strokeSettings": "Definições do traço",
+ "toolbar.style": "Estilo",
+ "toolbar.viewAndStroke": "Visualização e definições do traço",
+ "top.dismissError": "Dispensar erro",
+ "top.editGpxHint": "Altere a cor, a largura ou o alinhamento dos percursos",
+ "top.exportDisabled": "Importe uma imagem de mapa antes de exportar",
+ "top.exportHint": "Pré-visualize e transfira o mapa com desenhos e anotações",
+ "top.history": "Histórico",
+ "top.importGpxHint": "Importe um ficheiro GPX para sobrepor um percurso no mapa",
+ "top.importImageHint": "Carregue uma imagem de mapa (PNG, JPG) ou arraste um ficheiro para a área de desenho",
+ "top.installApp": "Instalar a aplicação",
+ "top.installHint": "Instale o O-Analysis como aplicação de ambiente de trabalho",
+ "top.new": "Novo",
+ "top.newHint": "Limpe a área de desenho e comece de novo",
+ "top.open": "Abrir",
+ "top.openHint": "Abra um projeto guardado ({ext})",
+ "top.projectName": "Nome do projeto",
+ "top.projects": "Projetos",
+ "top.projectsHint": "Alterne entre projetos guardados",
+ "top.redo": "Refazer",
+ "top.redoHint": "Refazer (⌘⇧Z / Ctrl+Shift+Z)",
+ "top.replaceImageHint": "Substituir a imagem de mapa atual",
+ "top.save": "Guardar",
+ "top.saveDisabled": "Nada para guardar ainda – importe um mapa ou adicione anotações primeiro",
+ "top.saveHint": "Guardar o projeto como {ext}",
+ "top.undo": "Anular",
+ "top.undoHint": "Anular a última ação (⌘Z / Ctrl+Z)",
+ "top.updateApp": "Atualizar a aplicação",
+ "top.updateHint": "Recarregue para usar a versão mais recente",
+ "touch.dismissTips": "Dispensar as sugestões",
+ "touch.tips": "Faça um pinça para ampliar, use dois dedos para mover. Toque numa ferramenta e depois no mapa para desenhar ou colocar marcadores."
+}
diff --git a/src/locales/sv.json b/src/locales/sv.json
new file mode 100644
index 0000000..e710480
--- /dev/null
+++ b/src/locales/sv.json
@@ -0,0 +1,232 @@
+{
+ "action.editGpx": "Redigera GPX",
+ "action.editImage": "Redigera bild",
+ "action.export": "Exportera",
+ "action.importGpx": "Importera GPX",
+ "action.importImage": "Importera bild",
+ "action.installApp": "Installera appen",
+ "action.newProject": "Nytt projekt",
+ "action.openProject": "Öppna projekt",
+ "action.projects": "Projekt",
+ "action.saveProject": "Spara projekt",
+ "action.updateApp": "Uppdatera appen",
+ "canvas.importHintDrop": "Importera en kartbild för att komma igång, eller släpp en fil här.",
+ "canvas.importHintTouch": "Importera en kartbild för att komma igång.",
+ "canvas.importMap": "Importera karta",
+ "canvas.loadingSample": "Läser in exempel…",
+ "canvas.noMap": "Ingen karta inläst",
+ "canvas.openSample": "Öppna exempelprojekt",
+ "color.channel": "{channel}-kanal",
+ "color.channelValue": "{channel}-värde",
+ "color.custom": "Egen färg",
+ "color.hex": "Hex",
+ "color.hexAria": "Hex-färg",
+ "common.apply": "Använd",
+ "common.cancel": "Avbryt",
+ "common.close": "Stäng",
+ "common.delete": "Ta bort",
+ "common.dismiss": "Stäng",
+ "common.errorGeneric": "Något gick fel",
+ "common.loading": "Laddar…",
+ "common.rename": "Byt namn",
+ "error.body": "O-Analysis stötte på ett oväntat fel. Att ladda om sidan brukar lösa det.",
+ "error.damagedEntry": "Ogiltig {label} i projektfilen (post {index} är skadad)",
+ "error.decodeImage": "Bildfilen kunde inte läsas in",
+ "error.decodeMap": "Kartbilden kunde inte läsas in",
+ "error.exportTooLarge": "Kartan är för stor för att exporteras på den här enheten",
+ "error.gpxImport": "GPX-importen misslyckades",
+ "error.imageImport": "Bildimporten misslyckades",
+ "error.imageTooLarge": "Bilden är för stor ({size}). Maxstorleken är 20 MB.",
+ "error.invalidEntry": "Ogiltig {label} i projektfilen",
+ "error.invalidMap": "Ogiltig kartbild i projektfilen",
+ "error.invalidName": "Ogiltigt projektnamn i projektfilen",
+ "error.invalidRotation": "Ogiltig rotation i projektfilen",
+ "error.labelAnnotations": "annoteringar",
+ "error.labelDrawings": "ritningar",
+ "error.labelTracks": "GPX-spår",
+ "error.loadSample": "Exempelprojektet kunde inte läsas in",
+ "error.noGpxPoints": "GPX-filen innehåller inga användbara spårpunkter",
+ "error.notGpx": "Inte en giltig GPX-fil",
+ "error.notImage": "”{name}” är inte en bildfil",
+ "error.notJson": "Projektfilen är inte giltig JSON",
+ "error.notOurs": "Inte en O-Analysis-projektfil",
+ "error.notProjectFile": "Inte en projektfil – förväntade {ext}",
+ "error.nothingToSave": "Inget att spara – importera en karta eller lägg till innehåll först",
+ "error.openProject": "Projektfilen kunde inte öppnas",
+ "error.projectNotFound": "Projektet kunde inte hittas",
+ "error.readImage": "Bildfilen kunde inte läsas",
+ "error.reload": "Ladda om sidan",
+ "error.renderExport": "Exportbilden kunde inte renderas",
+ "error.sampleNotFound": "Exempelprojektet hittades inte",
+ "error.title": "Något gick fel",
+ "error.unsupportedFile": "Filtypen stöds inte: ”{name}”",
+ "error.version": "Projektversionen stöds inte: {version}",
+ "export.download": "Ladda ner PNG",
+ "export.exporting": "Exporterar…",
+ "export.failed": "Exporten misslyckades – försök igen",
+ "export.fit": "Anpassa",
+ "export.generating": "Skapar förhandsvisning…",
+ "export.hint": "Scrolla för att zooma · dra för att panorera",
+ "export.includeSidebar": "Inkludera annoteringspanel",
+ "export.loadingPreview": "Läser in förhandsvisning…",
+ "export.previewAlt": "Förhandsvisning av export",
+ "export.resetZoom": "Återställ zoom",
+ "export.scaleNotice": "Kartan är större än vad den här enheten kan exportera i full storlek, så PNG-filen blir {percent} % av originalupplösningen.",
+ "export.sidebarWithComments": "Kommentarerna visas på kartan – stäng av panelen för att undvika dubbletter.",
+ "export.sidebarWithLabels": "Panelen listar varje markörbokstav med sin kommentar.",
+ "export.textSize": "Textstorlek",
+ "export.title": "Exportera",
+ "export.zoomIn": "Zooma in",
+ "export.zoomOut": "Zooma ut",
+ "gpx.adjustMap": "Justera på kartan",
+ "gpx.applyCrop": "Tillämpa beskärning",
+ "gpx.confirm": "Bekräfta",
+ "gpx.crop": "Beskär",
+ "gpx.cropAria": "Beskär spår",
+ "gpx.cropError": "Markeringen måste innehålla minst 2 punkter.",
+ "gpx.cropTitle": "Beskär spår",
+ "gpx.empty": "Inga GPX-spår inlästa.",
+ "gpx.fast": "Snabb (grön)",
+ "gpx.highHr": "Hög puls (ljusröd)",
+ "gpx.hr": "Puls",
+ "gpx.km": "{value} km",
+ "gpx.lineStyleAria": "Linjestil",
+ "gpx.lowHr": "Låg puls (mörkröd)",
+ "gpx.noTiming": "Ingen tidsdata i den här GPX-filen – beskär efter sträcka",
+ "gpx.opacity": "Opacitet",
+ "gpx.opacityShort": "Opak.",
+ "gpx.pace": "Tempo",
+ "gpx.remove": "Ta bort",
+ "gpx.removeAria": "Ta bort spår",
+ "gpx.slow": "Långsam (röd)",
+ "gpx.solid": "Heldragen",
+ "gpx.strokeWidth": "Linjebredd",
+ "gpx.tipAdjust": "Byt till kartan och dra ankarpunkterna",
+ "gpx.tipCrop": "Beskär spårets början och slut",
+ "gpx.tipRemove": "Ta bort spåret från projektet",
+ "gpx.title": "GPX-spår",
+ "gpx.toastTapHint": "Tryck på spåret för att lägga till punkter. Tryck länge på en punkt för att ta bort den.",
+ "gpx.trackColour": "Spårfärg {color}",
+ "gpx.upload": "Ladda upp",
+ "gpx.width": "Bredd",
+ "language.label": "Språk",
+ "legal.binding": "Den engelska versionen av dokumentet är den bindande texten. Översättningarna tillhandahålls för bekvämlighets skull.",
+ "legal.lastUpdated": "Senast uppdaterad: {date}",
+ "library.deleteAria": "Ta bort {name}",
+ "library.empty": "Inga sparade projekt än. Importera en karta för att börja.",
+ "library.noMap": "Ingen karta än",
+ "library.open": "Öppna",
+ "library.renameAria": "Byt namn på {name}",
+ "library.title": "Projekt",
+ "library.updated": "{map} · Uppdaterad {date}",
+ "menu.file": "Fil",
+ "menu.legal": "Juridiskt",
+ "menu.menu": "Meny",
+ "menu.more": "Fler åtgärder",
+ "menu.privacy": "Integritetspolicy",
+ "menu.terms": "Användarvillkor",
+ "notes.centerHint": "Klicka för att centrera på kartan",
+ "notes.commentPlaceholder": "Lägg till en kommentar…",
+ "notes.comments": "Kommentarer",
+ "notes.commentsHint": "Visa kommentarer direkt på kartan. Dra i den högra kanten på en vald kommentar för att ändra bredden.",
+ "notes.deleteAria": "Ta bort annotering {label}",
+ "notes.displayAria": "Visning av kartmarkörer",
+ "notes.empty": "Inga annoteringar än. Använd markörverktyget för att lägga till kommentarer på kartan.",
+ "notes.heading": "Annoteringar",
+ "notes.labels": "Etiketter",
+ "notes.labelsHint": "Visa bokstavsetiketter på kartmarkörerna",
+ "notes.removeHint": "Ta bort den här markören",
+ "notes.toggle": "Noteringar",
+ "notes.toggleAria": "Visa eller dölj annoteringar",
+ "project.untitled": "Namnlöst projekt",
+ "pwa.dismissUpdate": "Stäng uppdateringsmeddelandet",
+ "pwa.updateAvailable": "En ny version av O-Analysis finns tillgänglig.",
+ "pwa.updateNow": "Uppdatera nu",
+ "rotation.degrees": "{degrees} grader",
+ "rotation.group": "Kartrotation",
+ "rotation.label": "Rotation",
+ "rotation.reset": "Återställ rotation",
+ "rotation.resetAria": "Återställ rotationen till 0 grader",
+ "rotation.rotate": "Rotera",
+ "save.failed": "Automatisk sparning misslyckades",
+ "save.saved": "Sparad {time}",
+ "save.saving": "Sparar…",
+ "status.coords": "X: {x} Y: {y}",
+ "status.credit": "av Anthony Riley ❤️",
+ "status.fitAria": "Zoom {zoom} %. Anpassa kartan till skärmen",
+ "status.fitHint": "Anpassa kartan till skärmen",
+ "status.legal": "Juridiskt",
+ "status.noCoords": "X: — Y: —",
+ "status.privacy": "Integritet",
+ "status.terms": "Villkor",
+ "status.textSize": "Textstorlek",
+ "status.textSizeShort": "Text",
+ "status.tool": "Verktyg: {tool}",
+ "status.zoomIn": "Zooma in",
+ "status.zoomOut": "Zooma ut",
+ "stroke.color": "Färg",
+ "stroke.colorAria": "Färg {color}",
+ "stroke.custom": "Egen färg",
+ "stroke.customHint": "Välj en egen färg",
+ "stroke.opacity": "Opacitet",
+ "stroke.opacityHint": "Genomskinlighet för ritade linjer och markörer",
+ "stroke.useColor": "Använd {color}",
+ "stroke.width": "Bredd",
+ "stroke.widthHint": "Linje- och markörbredd",
+ "style.commentTextSize": "Textstorlek för kommentarer",
+ "tool.eraser": "Sudd",
+ "tool.eraser.tip": "Klicka eller dra över linjer och markörer för att ta bort dem.",
+ "tool.eraser.touchTip": "Tryck eller dra över linjer och markörer för att ta bort dem.",
+ "tool.gpx": "Justera GPX",
+ "tool.gpx.tip": "Klicka på spåret för att lägga till punkter, dra för att flytta dem och högerklicka på en punkt för att ta bort den.",
+ "tool.gpx.touchTip": "Tryck på spåret för att lägga till punkter. Tryck länge på en punkt för att ta bort den.",
+ "tool.gpxNeedsFile": "Importera en GPX-fil först",
+ "tool.gpxShort": "GPX",
+ "tool.hotkeyLabel": "{tip} ({key})",
+ "tool.line": "Rita",
+ "tool.line.tip": "Klicka och dra för att rita linjer på kartan.",
+ "tool.line.touchTip": "Dra med fingret för att rita linjer på kartan.",
+ "tool.marker": "Markör",
+ "tool.marker.tip": "Klicka för att placera en numrerad markör med en kommentar.",
+ "tool.marker.touchTip": "Tryck för att placera en numrerad markör med en kommentar.",
+ "tool.pan": "Panorera",
+ "tool.pan.tip": "Dra kartan för att flytta runt. Håll ned mellanslag eller mittenknappen för tillfällig panorering.",
+ "tool.pan.touchTip": "Dra med ett finger, eller använd två fingrar för att panorera i alla verktyg.",
+ "tool.select": "Markera",
+ "tool.select.tip": "Klicka på linjer eller markörer för att markera dem. Tryck på Delete för att ta bort.",
+ "tool.select.touchTip": "Tryck på linjer eller markörer för att markera dem.",
+ "toolbar.annotations": "Annoteringar",
+ "toolbar.drawingTools": "Ritverktyg",
+ "toolbar.notes": "Noteringar",
+ "toolbar.strokeSettings": "Linjeinställningar",
+ "toolbar.style": "Stil",
+ "toolbar.viewAndStroke": "Vy och linjeinställningar",
+ "top.dismissError": "Stäng felmeddelandet",
+ "top.editGpxHint": "Ändra spårfärg, bredd eller justering",
+ "top.exportDisabled": "Importera en kartbild innan du exporterar",
+ "top.exportHint": "Förhandsgranska och ladda ner kartan med ritningar och annoteringar",
+ "top.history": "Historik",
+ "top.importGpxHint": "Importera en GPX-fil för att lägga en rutt över kartan",
+ "top.importImageHint": "Ladda upp en kartbild – PNG, JPG eller släpp en fil på duken",
+ "top.installApp": "Installera appen",
+ "top.installHint": "Installera O-Analysis som en skrivbordsapp",
+ "top.new": "Nytt",
+ "top.newHint": "Rensa duken och börja om",
+ "top.open": "Öppna",
+ "top.openHint": "Öppna ett sparat projekt ({ext})",
+ "top.projectName": "Projektnamn",
+ "top.projects": "Projekt",
+ "top.projectsHint": "Byt mellan sparade projekt",
+ "top.redo": "Gör om",
+ "top.redoHint": "Gör om (⌘⇧Z / Ctrl+Shift+Z)",
+ "top.replaceImageHint": "Ersätt den aktuella kartbilden",
+ "top.save": "Spara",
+ "top.saveDisabled": "Inget att spara ännu – importera en karta eller lägg till annoteringar först",
+ "top.saveHint": "Spara projektet som {ext}",
+ "top.undo": "Ångra",
+ "top.undoHint": "Ångra senaste åtgärden (⌘Z / Ctrl+Z)",
+ "top.updateApp": "Uppdatera appen",
+ "top.updateHint": "Ladda om för att använda den senaste versionen",
+ "touch.dismissTips": "Stäng tipsen",
+ "touch.tips": "Nyp för att zooma, använd två fingrar för att panorera. Tryck på ett verktyg och sedan på kartan för att rita eller placera markörer."
+}
diff --git a/src/main.tsx b/src/main.tsx
index 7eb46c5..d671406 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -5,16 +5,20 @@ import './index.css'
import App from './App.tsx'
import AppErrorBoundary from './components/AppErrorBoundary.tsx'
import { initServiceWorker } from './pwa.ts'
+import { initI18n } from './i18n'
// Avoid 0×0 shadow-cache canvases when browser zoom lowers devicePixelRatio.
Konva.pixelRatio = 1
initServiceWorker()
-createRoot(document.getElementById('root')!).render(
-
-
-
-
- ,
-)
+// Load the saved or detected language before the first render, so nothing flashes in English
+void initI18n().then(() => {
+ createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+ )
+})
diff --git a/src/persistence/library.ts b/src/persistence/library.ts
index fb99791..c6d4697 100644
--- a/src/persistence/library.ts
+++ b/src/persistence/library.ts
@@ -7,6 +7,7 @@ import {
del as delLegacy,
get as getLegacy,
} from 'idb-keyval'
+import { t } from '../i18n'
import type {
Annotation,
DrawnPath,
@@ -15,8 +16,14 @@ import type {
MarkerDisplayMode,
} from '../types'
+/** Stored name for a project that has not been named. Shown translated via projectDisplayName(). */
export const DEFAULT_PROJECT_NAME = 'Untitled project'
+/** The name to show on screen: the default name is translated, anything the user typed is shown as-is. */
+export function projectDisplayName(name: string): string {
+ return name === DEFAULT_PROJECT_NAME ? t('project.untitled') : name
+}
+
/** Name for a project that has none: the map's name, or the default. */
export function defaultProjectName(mapImageName: string | null | undefined): string {
const base = mapImageName?.replace(/\.[^.]+$/, '').trim()
diff --git a/src/persistence/projectStorage.ts b/src/persistence/projectStorage.ts
index fbd2431..040ec27 100644
--- a/src/persistence/projectStorage.ts
+++ b/src/persistence/projectStorage.ts
@@ -1,5 +1,6 @@
import type { PersistStorage, StorageValue } from 'zustand/middleware'
import { hasUnsavedEdits, setSaveState } from '../saveStatus'
+import { t } from '../i18n'
import type { MapImage } from '../types'
import {
dataUrlToBlob,
@@ -63,7 +64,7 @@ async function makeThumbnail(dataUrl: string): Promise {
const img = await new Promise((resolve, reject) => {
const el = new Image()
el.onload = () => resolve(el)
- el.onerror = () => reject(new Error('Could not decode map image'))
+ el.onerror = () => reject(new Error(t('error.decodeMap')))
el.src = dataUrl
})
const scale = Math.min(1, THUMBNAIL_MAX_PX / Math.max(img.width, img.height))
diff --git a/src/projectLibrary.ts b/src/projectLibrary.ts
index fe2d90c..e060cda 100644
--- a/src/projectLibrary.ts
+++ b/src/projectLibrary.ts
@@ -1,4 +1,5 @@
import { useAppStore, type ProjectData } from './store'
+import { t } from './i18n'
import {
DEFAULT_PROJECT_NAME,
defaultProjectName,
@@ -55,7 +56,7 @@ export async function openLibraryProject(id: string): Promise {
if (id === useAppStore.getState().projectId) return
await flushProjectSaves()
const state = await readProjectState(id)
- if (!state) throw new Error('That project could not be found')
+ if (!state) throw new Error(t('error.projectNotFound'))
adoptProjectState(state)
await setActiveProjectId(id)
useAppStore.getState().loadProject(state)
diff --git a/src/utils/export.ts b/src/utils/export.ts
index d28a885..caae0e7 100644
--- a/src/utils/export.ts
+++ b/src/utils/export.ts
@@ -1,8 +1,10 @@
import { stageRef } from '../stageRef'
+import { t } from '../i18n'
import { contentGroupRef } from '../contentGroupRef'
import { useAppStore } from '../store'
import { AnalyticsEvents, trackEvent } from '../analytics'
import { exportScaleFor } from './exportScale'
+import { wrapText } from './textWrap'
import { markerLabel } from './labels'
import { drawMapMarkers } from './exportMarkers'
import type { Annotation } from '../types'
@@ -18,7 +20,7 @@ let markersSuppressed = false
/** Throws when the browser could not encode the canvas (e.g. it exceeded a canvas limit). */
function assertEncodedPng(url: string): string {
if (!url.startsWith('data:image/png') || url.length < 64) {
- throw new Error('This map is too large to export on this device')
+ throw new Error(t('error.exportTooLarge'))
}
return url
}
@@ -32,7 +34,7 @@ function loadImage(src: string): Promise {
return new Promise((resolve, reject) => {
const img = new window.Image()
img.onload = () => resolve(img)
- img.onerror = () => reject(new Error('Failed to render export image'))
+ img.onerror = () => reject(new Error(t('error.renderExport')))
img.src = src
})
}
@@ -91,32 +93,6 @@ export async function renderExportPreviews(
}
/** Splits text into lines that fit maxWidth, honouring explicit newlines. */
-function wrapText(
- ctx: CanvasRenderingContext2D,
- text: string,
- maxWidth: number,
-): string[] {
- const lines: string[] = []
- for (const paragraph of text.split('\n')) {
- const words = paragraph.split(/\s+/).filter(Boolean)
- if (words.length === 0) {
- lines.push('')
- continue
- }
- let line = words[0]
- for (const word of words.slice(1)) {
- if (ctx.measureText(`${line} ${word}`).width <= maxWidth) {
- line = `${line} ${word}`
- } else {
- lines.push(line)
- line = word
- }
- }
- lines.push(line)
- }
- return lines
-}
-
interface SidebarEntry {
annotation: Annotation
index: number
@@ -211,7 +187,7 @@ function drawSidebar(
if (c === 0) {
ctx.fillStyle = '#18181b'
ctx.font = `600 ${fontSize * 1.1}px ${font}`
- ctx.fillText('Annotations', colX + pad, pad + fontSize)
+ ctx.fillText(t('notes.heading'), colX + pad, pad + fontSize)
}
if (c > 0) {
diff --git a/src/utils/files.ts b/src/utils/files.ts
index 9163c5a..35511cf 100644
--- a/src/utils/files.ts
+++ b/src/utils/files.ts
@@ -2,6 +2,7 @@ import { useAppStore } from '../store'
import { AnalyticsEvents, trackEvent } from '../analytics'
import { buildTrack, parseGpx } from './gpx'
import { isProjectFile, openProjectFile } from './project'
+import { t } from '../i18n'
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024
@@ -15,7 +16,7 @@ function loadImageElement(src: string): Promise {
return new Promise((resolve, reject) => {
const img = new window.Image()
img.onload = () => resolve(img)
- img.onerror = () => reject(new Error('Could not decode the image file'))
+ img.onerror = () => reject(new Error(t('error.decodeImage')))
img.src = src
})
}
@@ -24,7 +25,7 @@ function readFileAsDataUrl(file: File): Promise {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
- reader.onerror = () => reject(new Error('Could not read the image file'))
+ reader.onerror = () => reject(new Error(t('error.readImage')))
reader.readAsDataURL(file)
})
}
@@ -38,12 +39,12 @@ export async function importImageFile(
setImportError(null)
if (!file.type.startsWith('image/')) {
- setImportError(`"${file.name}" is not an image file`)
+ setImportError(t('error.notImage', { name: file.name }))
return
}
if (file.size > MAX_IMAGE_BYTES) {
setImportError(
- `Image is too large (${formatMb(file.size)}). Maximum size is 20MB.`,
+ t('error.imageTooLarge', { size: formatMb(file.size) }),
)
return
}
@@ -63,7 +64,7 @@ export async function importImageFile(
source,
})
} catch (error) {
- setImportError(error instanceof Error ? error.message : 'Image import failed')
+ setImportError(error instanceof Error ? error.message : t('error.imageImport'))
}
}
@@ -90,7 +91,7 @@ export async function importGpxFile(
})
} catch (error) {
store.setImportError(
- error instanceof Error ? error.message : 'GPX import failed',
+ error instanceof Error ? error.message : t('error.gpxImport'),
)
}
}
@@ -107,7 +108,7 @@ export async function importDroppedFiles(files: FileList): Promise {
} else {
useAppStore
.getState()
- .setImportError(`Unsupported file type: "${file.name}"`)
+ .setImportError(t('error.unsupportedFile', { name: file.name }))
}
}
}
diff --git a/src/utils/gpx.ts b/src/utils/gpx.ts
index f25b301..e5404fa 100644
--- a/src/utils/gpx.ts
+++ b/src/utils/gpx.ts
@@ -4,6 +4,7 @@ import {
defaultPaceScale,
hasTrackTiming,
} from './gpxMetrics'
+import { t } from '../i18n'
interface ParsedGpx {
name: string
@@ -59,7 +60,7 @@ function parseHeartRateBpm(node: Element): number | undefined {
export function parseGpx(xml: string, fileName: string): ParsedGpx {
const doc = new DOMParser().parseFromString(xml, 'application/xml')
if (doc.querySelector('parsererror')) {
- throw new Error('Not a valid GPX file')
+ throw new Error(t('error.notGpx'))
}
let pointNodes = Array.from(doc.querySelectorAll('trkpt'))
@@ -83,7 +84,7 @@ export function parseGpx(xml: string, fileName: string): ParsedGpx {
}
if (projected.length < 2) {
- throw new Error('GPX file contains no usable track points')
+ throw new Error(t('error.noGpxPoints'))
}
let minX = Infinity
diff --git a/src/utils/markerComments.ts b/src/utils/markerComments.ts
index 80f12a4..6d335ee 100644
--- a/src/utils/markerComments.ts
+++ b/src/utils/markerComments.ts
@@ -1,3 +1,5 @@
+import { wrapText } from './textWrap'
+
export const COMMENT_FONT_SIZE = 8
export const COMMENT_MAX_TEXT_WIDTH = 68
export const COMMENT_PADDING = 3
@@ -79,32 +81,6 @@ export function rightEdgeFromTextWidth(
return ((clampCommentBoxWidth(textWidth) + COMMENT_PADDING * 2) / 2) * scale
}
-function wrapText(
- ctx: CanvasRenderingContext2D,
- text: string,
- maxWidth: number,
-): string[] {
- const lines: string[] = []
- for (const paragraph of text.split('\n')) {
- const words = paragraph.split(/\s+/).filter(Boolean)
- if (words.length === 0) {
- lines.push('')
- continue
- }
- let line = words[0]
- for (const word of words.slice(1)) {
- if (ctx.measureText(`${line} ${word}`).width <= maxWidth) {
- line = `${line} ${word}`
- } else {
- lines.push(line)
- line = word
- }
- }
- lines.push(line)
- }
- return lines
-}
-
export interface CommentBoxMetrics {
width: number
height: number
diff --git a/src/utils/project.ts b/src/utils/project.ts
index a78c254..f9ba27a 100644
--- a/src/utils/project.ts
+++ b/src/utils/project.ts
@@ -1,6 +1,7 @@
import { useAppStore, type ProjectData } from '../store'
import { AnalyticsEvents, trackEvent } from '../analytics'
import { importProject } from '../projectLibrary'
+import { t } from '../i18n'
import { PROJECT_FORMAT, PROJECT_VERSION, validateProjectFile, type ProjectFile } from './projectValidation'
export const PROJECT_FILE_EXTENSION = '.anal'
@@ -31,7 +32,7 @@ export function saveProjectFile(): void {
rotation: useAppStore.getState().viewport.rotation,
}
if (!hasProjectContent(project)) {
- setImportError('Nothing to save — import a map or add content first')
+ setImportError(t('error.nothingToSave'))
return
}
@@ -68,14 +69,14 @@ export async function openSampleProject(): Promise {
try {
const response = await fetch('/sample.anal')
if (!response.ok) {
- throw new Error('Sample project not found')
+ throw new Error(t('error.sampleNotFound'))
}
const parsed = validateProjectFile(JSON.parse(await response.text()))
await importProject(parsed, parsed.name)
trackEvent(AnalyticsEvents.OPEN_SAMPLE_PROJECT)
} catch (error) {
setImportError(
- error instanceof Error ? error.message : 'Failed to load sample project',
+ error instanceof Error ? error.message : t('error.loadSample'),
)
}
}
@@ -89,7 +90,7 @@ export async function openProjectFile(
setImportError(null)
if (!isProjectFile(file)) {
- setImportError(`Not a project file — expected ${PROJECT_FILE_EXTENSION}`)
+ setImportError(t('error.notProjectFile', { ext: PROJECT_FILE_EXTENSION }))
return
}
@@ -105,7 +106,7 @@ export async function openProjectFile(
})
} catch (error) {
setImportError(
- error instanceof Error ? error.message : 'Failed to open project file',
+ error instanceof Error ? error.message : t('error.openProject'),
)
}
}
diff --git a/src/utils/projectValidation.ts b/src/utils/projectValidation.ts
index 9063131..06ac29e 100644
--- a/src/utils/projectValidation.ts
+++ b/src/utils/projectValidation.ts
@@ -1,4 +1,5 @@
import type { ProjectData } from '../store'
+import { t } from '../i18n'
export const PROJECT_FORMAT = 'map-analysis'
export const PROJECT_VERSION = 1
@@ -32,12 +33,12 @@ function assertItems(
isValid: (item: unknown) => boolean,
): void {
if (!Array.isArray(items)) {
- throw new Error(`Invalid ${label} in project file`)
+ throw new Error(t('error.invalidEntry', { label }))
}
const badIndex = items.findIndex((item) => !isValid(item))
if (badIndex !== -1) {
throw new Error(
- `Invalid ${label} in project file (entry ${badIndex + 1} is damaged)`,
+ t('error.damagedEntry', { label, index: badIndex + 1 }),
)
}
}
@@ -93,27 +94,27 @@ function isValidAnnotation(v: unknown): boolean {
export function validateProjectFile(raw: unknown): ProjectFile {
if (!isRecord(raw)) {
- throw new Error('Project file is not valid JSON')
+ throw new Error(t('error.notJson'))
}
if (raw.format !== PROJECT_FORMAT) {
- throw new Error('Not an O-Analysis project file')
+ throw new Error(t('error.notOurs'))
}
if (raw.version !== PROJECT_VERSION) {
- throw new Error(`Unsupported project version: ${String(raw.version)}`)
+ throw new Error(t('error.version', { version: String(raw.version) }))
}
if (raw.mapImage !== null && !isValidMapImage(raw.mapImage)) {
- throw new Error('Invalid map image in project file')
+ throw new Error(t('error.invalidMap'))
}
if (raw.name !== undefined && typeof raw.name !== 'string') {
- throw new Error('Invalid project name in project file')
+ throw new Error(t('error.invalidName'))
}
if (raw.rotation !== undefined && !isFiniteNumber(raw.rotation)) {
- throw new Error('Invalid rotation in project file')
+ throw new Error(t('error.invalidRotation'))
}
- assertItems('GPX tracks', raw.tracks, isValidTrack)
- assertItems('drawings', raw.paths, isValidPath)
- assertItems('annotations', raw.annotations, isValidAnnotation)
+ assertItems(t('error.labelTracks'), raw.tracks, isValidTrack)
+ assertItems(t('error.labelDrawings'), raw.paths, isValidPath)
+ assertItems(t('error.labelAnnotations'), raw.annotations, isValidAnnotation)
return raw as unknown as ProjectFile
}
diff --git a/src/utils/textWrap.test.ts b/src/utils/textWrap.test.ts
new file mode 100644
index 0000000..fe9e3bc
--- /dev/null
+++ b/src/utils/textWrap.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import { wrapText } from './textWrap'
+
+/** Stub measuring context: every character is 10px wide. */
+const ctx = {
+ measureText: (text: string) => ({ width: text.length * 10 }),
+} as unknown as CanvasRenderingContext2D
+
+describe('wrapText', () => {
+ it('wraps on spaces to the maximum width', () => {
+ expect(wrapText(ctx, 'one two three', 80)).toEqual(['one two', 'three'])
+ })
+
+ it('keeps explicit newlines and blank lines', () => {
+ expect(wrapText(ctx, 'a\n\nb', 200)).toEqual(['a', '', 'b'])
+ })
+
+ it('breaks a single word that is wider than the column (long Finnish compounds)', () => {
+ const lines = wrapText(ctx, 'Lentokonehangaarikonsultointi', 100)
+ expect(lines.length).toBeGreaterThan(1)
+ expect(lines.join('')).toBe('Lentokonehangaarikonsultointi')
+ for (const line of lines) {
+ expect(line.length * 10).toBeLessThanOrEqual(100)
+ }
+ })
+
+ it('does not insert spaces into a broken word', () => {
+ expect(wrapText(ctx, 'abcdefghij', 30)).toEqual(['abc', 'def', 'ghi', 'j'])
+ })
+
+ it('keeps a short word on the line where it fits after a broken word', () => {
+ expect(wrapText(ctx, 'abcdefgh ok', 80)).toEqual(['abcdefgh', 'ok'])
+ })
+})
diff --git a/src/utils/textWrap.ts b/src/utils/textWrap.ts
new file mode 100644
index 0000000..e39bcf8
--- /dev/null
+++ b/src/utils/textWrap.ts
@@ -0,0 +1,58 @@
+/**
+ * Canvas text wrapping for the exported annotation sidebar and map comments.
+ * Works with any 2D context, so tests can pass a stub that measures text.
+ */
+/** Splits one word that is wider than maxWidth (e.g. long Finnish compounds) into pieces that fit. */
+function breakWord(
+ ctx: CanvasRenderingContext2D,
+ word: string,
+ maxWidth: number,
+): string[] {
+ const pieces: string[] = []
+ let piece = ''
+ for (const char of word) {
+ if (piece && ctx.measureText(piece + char).width > maxWidth) {
+ pieces.push(piece)
+ piece = char
+ } else {
+ piece += char
+ }
+ }
+ if (piece) pieces.push(piece)
+ return pieces
+}
+
+/** Wraps text to maxWidth, honouring explicit newlines and breaking words that cannot fit. */
+export function wrapText(
+ ctx: CanvasRenderingContext2D,
+ text: string,
+ maxWidth: number,
+): string[] {
+ const lines: string[] = []
+ for (const paragraph of text.split('\n')) {
+ const words = paragraph.split(/\s+/).filter(Boolean)
+ if (words.length === 0) {
+ lines.push('')
+ continue
+ }
+ let line = ''
+ for (const word of words) {
+ const candidate = line ? `${line} ${word}` : word
+ if (ctx.measureText(candidate).width <= maxWidth) {
+ line = candidate
+ continue
+ }
+ if (line) lines.push(line)
+ line = ''
+ if (ctx.measureText(word).width <= maxWidth) {
+ line = word
+ continue
+ }
+ const pieces = breakWord(ctx, word, maxWidth)
+ lines.push(...pieces.slice(0, -1))
+ line = pieces[pieces.length - 1] ?? ''
+ }
+ lines.push(line)
+ }
+ return lines
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 6830b6f..345ba12 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -6,6 +6,7 @@
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
+ "resolveJsonModule": true,
"skipLibCheck": true,
/* Bundler mode */
diff --git a/vite.config.ts b/vite.config.ts
index e185455..309ae34 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,6 +2,9 @@ import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
+/** Vite names each language chunk after its locale code, e.g. sv-*.js or pt-BR-*.js */
+const LOCALE_CHUNK_PREFIX = '{sv,nb,fi,da,es,fr,pt-BR,pt-PT}-'
+
// https://vite.dev/config/
export default defineConfig(({ command, mode }) => {
const env = loadEnv(mode, process.cwd(), '')
@@ -61,6 +64,15 @@ export default defineConfig(({ command, mode }) => {
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
+ // Language packs download on first use rather than on install (see runtimeCaching)
+ globIgnores: [`**/${LOCALE_CHUNK_PREFIX}*.js`],
+ runtimeCaching: [
+ {
+ urlPattern: new RegExp(`/assets/${LOCALE_CHUNK_PREFIX}[^/]*\\.js$`),
+ handler: 'CacheFirst',
+ options: { cacheName: 'language-packs' },
+ },
+ ],
},
}),
],