fix(cctv): keep Icelandic labels intact and treat 'og' facings as ambiguous

- Label repeat-collapse uses letter lookarounds instead of ASCII-only \b, so
  'Þverá Árnessýslu' is no longer joined and 'séð séð' collapses.
- 'til austurs og vesturs' and 'í norður og suður' name two facings and keep
  the low-confidence pose.
- Fallback name uses the frame stem when the station number is missing.
- Tests: label and heading regressions, dedupe keeps the last row, cap clamp,
  high-confidence pose, frame redirect pin; file added to format scope.
- Credit names the dataset, links the terms and states no endorsement.
This commit is contained in:
Said Turksever committed 2026-11-07 14:38:52 +00:00
1 parent 8455c4bce1
commit 39d70fa1c0
5 files changed
+136 -20

No files matched your search

+1 -1
View File
@@ -179,7 +179,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent.
- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`.
- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above in the Data attribution popover and carries the licence string on every camera. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_ENABLED=0`.
- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above (licensor, dataset, CC BY 4.0, terms link and no-endorsement note) in the Data attribution popover, and every camera record carries the licence string. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_ENABLED=0`.
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
- **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK.
+1
View File
@@ -583,6 +583,7 @@
"src/data/cctvHk.test.mjs",
"src/data/cctvVegvesen.test.mjs",
"src/data/cctvA22.test.mjs",
"src/data/cctvIceland.test.mjs",
"src/data/cctvNyc.test.mjs",
"src/data/cctvHouston.test.mjs",
"src/data/cctvNswSource.test.mjs",
+19 -8
View File
@@ -2051,15 +2051,22 @@ const ICELAND_DIRECTION_HEADINGS = {
vestur: 270,
norðvestur: 315,
};
const ICELAND_DIRECTION_RE =
/(?<![\p{L}])(?:til|í|séð)\s+(norðaustur|norðvestur|suðaustur|suðvestur|norður|suður|austur|vestur)s?(?![\p{L}])/gu;
const ICELAND_DIRECTION_WORD =
'(norðaustur|norðvestur|suðaustur|suðvestur|norður|suður|austur|vestur)s?';
/** A facing, plus a second one joined by "og" ("til austurs og vesturs"). */
const ICELAND_DIRECTION_RE = new RegExp(
`(?<![\\p{L}])(?:til|í|séð)\\s+${ICELAND_DIRECTION_WORD}` +
`(?:\\s+og\\s+(?:til\\s+|í\\s+)?${ICELAND_DIRECTION_WORD})?(?![\\p{L}])`,
'gu',
);
/**
* The compass heading an Icelandic view description states, or NaN. Only an
* explicit "til <átt>", "í <átt>" or "séð <átt>" counts: a bare place name or
* a road direction ("Miklabraut austur") is not a facing. A description naming two
* different directions is ambiguous and yields NaN, and "niður á veg" (down
* onto the road) states none.
* a road direction ("Miklabraut austur") is not a facing. A description naming
* two different directions ("til norðurs og til suðurs", "til austurs og
* vesturs") is ambiguous and yields NaN, and "niður á veg" (down onto the
* road) states none.
*
* @param {string} text - The feed's `Skyring` field.
* @returns {number} Heading in degrees [0..360), or NaN.
@@ -2069,7 +2076,9 @@ export function icelandHeadingFromDescription(text) {
for (const match of String(text || '')
.toLowerCase()
.matchAll(ICELAND_DIRECTION_RE)) {
headings.add(ICELAND_DIRECTION_HEADINGS[match[1]]);
for (const word of [match[1], match[2]]) {
if (word) headings.add(ICELAND_DIRECTION_HEADINGS[word]);
}
}
return headings.size === 1 ? headings.values().next().value : NaN;
}
@@ -2097,14 +2106,16 @@ export function icelandCameraToSource(row) {
const cameraId = `is-vegagerdin-${stem}`;
const description = String(row.Skyring || '')
.replace(/\s+/g, ' ')
.replace(/\b(\p{L}+) \1\b/giu, '$1')
// Letter lookarounds, not \b: \b is ASCII-only and would join "Þverá
// Árnessýslu" while missing "séð séð".
.replace(/(?<!\p{L})(\p{L}+) \1(?!\p{L})/giu, '$1')
.trim();
const station = String(row.Myndavel || '').trim();
const heading = icelandHeadingFromDescription(description);
const hasHeading = Number.isFinite(heading);
return {
id: cameraId,
name: description || station || `Vegagerðin ${row.Maelist_nr}`,
name: description || station || `Vegagerðin ${row.Maelist_nr ?? stem}`,
city: 'Iceland',
cityId: 'iceland',
provider: 'Vegagerðin (IRCA)',
+111 -9
View File
@@ -10,6 +10,7 @@ import {
ICELAND_IMAGE_ORIGIN,
ICELAND_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
import { isLikelyIcelandCoordinate } from '../../server/providers/cctv/normalize.js';
import {
icelandCameraToSource,
@@ -76,6 +77,10 @@ test('an Iceland row maps to a source on the pinned frame directory', () => {
assert.equal(source.lon, -21.4);
assert.equal(source.headingDeg, 270);
assert.equal(source.headingConfidence, 'high');
assert.equal(source.pitchDeg, -24);
assert.equal(source.fovDeg, 56);
assert.equal(source.rangeM, 210);
assert.equal(source.mountHeightM, 10);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'iceland-vegagerdin');
assert.equal(source.code, 'HELLISHEIÐI');
@@ -109,6 +114,27 @@ test('the label collapses the feed’s doubled spaces and repeated words', () =>
).name,
'Strandarheiði séð niður á veg til austurs',
);
const label = (Skyring) => icelandCameraToSource(row({ Skyring })).name;
// Repeats of words with Icelandic letters collapse too.
assert.equal(
label('Hellisheiði séð séð til austurs'),
'Hellisheiði séð til austurs',
);
assert.equal(label('Brú á á Jökulsá'), 'Brú á Jökulsá');
// A word ending in a letter the next word starts with is not a repeat.
for (const text of [
'Þverá Árnessýslu',
'brú Úlfarsá',
'Vesturlandsvegur við Elliðaá Ártúnsbrekku',
])
assert.equal(label(text), text);
});
test('a row with no label, station or station number is named by its frame', () => {
const source = icelandCameraToSource(
row({ Skyring: '', Myndavel: '', Maelist_nr: undefined }),
);
assert.equal(source.name, 'Vegagerðin hellisheidi_1');
});
test('only an explicit facing sets a heading', () => {
@@ -123,6 +149,8 @@ test('only an explicit facing sets a heading', () => {
['Staður séð í suðvestur', 225],
['Staður séð til norðvesturs', 315],
['STAÐUR SÉÐ TIL VESTURS', 270],
['Brú séð til austurs og Selfoss', 90], // "og" then a place, not a facing
['Staður séð til austurs og Vesturlandsvegur', 90],
];
for (const [text, heading] of cases) {
assert.equal(icelandHeadingFromDescription(text), heading, text);
@@ -137,6 +165,9 @@ test('only an explicit facing sets a heading', () => {
'Norðurárdalur niður á veg', // a place name that contains a cardinal
'Staður séð til suðurlands', // a longer word, not "suður"
'Staður séð til norðurs og til suðurs', // two facings: ambiguous
'Staður séð til austurs og vesturs', // the second facing shares "til"
'Staður séð í norður og suður',
'Staður séð í norður og í suður',
'Norðfjarðargöng séð til Eskifjarðar',
]) {
assert.ok(Number.isNaN(icelandHeadingFromDescription(text)), String(text));
@@ -189,10 +220,16 @@ test('the Iceland loader reads the list with redirects refused and keeps the nea
}),
);
// Reykjavík cameras come last in the feed but first in the priority order.
// A frame the feed repeats keeps its last row, as the other packs do.
rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Skyring: 'Old' }));
rows.push(
row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Breidd: 64.1466, Lengd: -21.9426 }),
row({
Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`,
Skyring: 'Kringlumýrarbraut',
Breidd: 64.1466,
Lengd: -21.9426,
}),
);
rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg` })); // repeated frame
const seen = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
@@ -202,6 +239,8 @@ test('the Iceland loader reads the list with redirects refused and keeps the nea
assert.deepEqual(seen, [[ICELAND_CAMERAS_URL, 'manual']]);
assert.equal(sources.length, 8);
assert.equal(sources[0].id, 'is-vegagerdin-rvk_1');
assert.equal(sources[0].name, 'Kringlumýrarbraut');
assert.equal(sources[0].lat, 64.1466);
assert.equal(
new Set(sources.map((s) => s.id)).size,
sources.length,
@@ -213,8 +252,9 @@ test('the Iceland loader reads the list with redirects refused and keeps the nea
test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => {
t.mock.method(console, 'log', () => {});
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
const rows = Array.from({ length: DEFAULT_ICELAND_MAX_SOURCES + 40 }, (_, i) =>
row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
const rows = Array.from(
{ length: DEFAULT_ICELAND_MAX_SOURCES + 40 },
(_, i) => row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
);
t.mock.method(globalThis, 'fetch', async () => json(rows));
assert.equal(
@@ -223,17 +263,71 @@ test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => {
);
});
test('CCTV_ICELAND_MAX_SOURCES is clamped to 8..600 and ignored when not a number', async (t) => {
t.mock.method(console, 'log', () => {});
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
const rows = Array.from({ length: 650 }, (_, i) =>
row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
);
t.mock.method(globalThis, 'fetch', async () => json(rows));
for (const [value, expected] of [
['0', 8],
['3', 8],
['1000', 600],
['abc', DEFAULT_ICELAND_MAX_SOURCES],
]) {
process.env.CCTV_ICELAND_MAX_SOURCES = value;
assert.equal(
(await loadIcelandSourcesFromOpenData()).length,
expected,
value,
);
}
});
test('a redirect on the Iceland frame host stays inside vefmyndavelar/', async () => {
const frame = `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`;
const redirectTo = (location) => async (url) =>
url === frame
? new Response(null, { status: 302, headers: { location } })
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
for (const location of [
'/vgdata/other/x.jpg',
'/vgdata/vefmyndavelar/../x.jpg',
'/',
])
assert.equal(
await fetchWithinHost(frame, {}, redirectTo(location)),
null,
location,
);
const followed = await fetchWithinHost(
frame,
{},
redirectTo('/vgdata/vefmyndavelar/hellisheidi_1b.jpg'),
);
assert.equal(await followed.text(), 'frame');
});
test('the Iceland loader fails soft on HTTP errors, bad payloads and network errors', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => new Response('x', { status: 503 }));
t.mock.method(
globalThis,
'fetch',
async () => new Response('x', { status: 503 }),
);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => json({ rows: [] }));
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.method(globalThis, 'fetch', async () => new Response('<html>', { status: 200 }));
t.mock.method(
globalThis,
'fetch',
async () => new Response('<html>', { status: 200 }),
);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
@@ -245,11 +339,17 @@ test('the Iceland catalog fetch refuses redirects and oversized bodies', async (
t.mock.method(console, 'warn', () => {});
const redirected = streamingResponse({
status: 302,
headers: { location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1' },
headers: {
location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1',
},
});
t.mock.method(globalThis, 'fetch', async () => redirected.response);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
assert.equal(redirected.state.cancelled, true, 'the redirect body is cancelled');
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
@@ -287,7 +387,9 @@ test('the Iceland catalog fetch refuses redirects and oversized bodies', async (
test('CCTV catalog merges Iceland cameras and CCTV_ICELAND_ENABLED=0 skips the request', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-'));
const sourceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-'),
);
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
withEnv(t, {
CCTV_SOURCES_FILE: undefined,
+4 -2
View File
@@ -324,9 +324,11 @@ export const DATA_CREDITS = [
{
key: 'iceland-cctv',
html:
'CCTV cameras &amp; frames (Iceland): ' +
'CCTV cameras &amp; frames (Iceland): road camera images, fetched live, from the ' +
'<a href="https://www.vegagerdin.is/" target="_blank" rel="noopener">Icelandic Road and Coastal Administration (Vegagerðin)</a> — ' +
'<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>',
'<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a> ' +
'(<a href="https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions" target="_blank" rel="noopener">terms</a>); ' +
'no endorsement by IRCA is implied',
},
{
key: 'kingcounty-cctv',