Compare commits

...
13 Commits
Author SHA1 Message Date
raucao 0f6272006c 1.34.4
CI / Lint (push) Successful in 1m8s
CI / Test (push) Successful in 1m27s
2026-09-24 19:08:53 +02:00
raucao 85de25032e Merge pull request 'Add operator info to place details' (#111) from feature/operator into master
CI / Lint (push) Successful in 1m3s
CI / Test (push) Successful in 1m22s
Reviewed-on: #111
2026-09-24 17:07:24 +00:00
raucao b9103b28a9 Add operator info to place details
CI / Lint (pull_request) Successful in 1m5s
CI / Test (pull_request) Successful in 1m23s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-09-24 19:02:27 +02:00
raucao b8a9c71657 Update README
CI / Lint (push) Successful in 1m3s
CI / Test (push) Successful in 1m21s
2026-09-21 12:09:38 +02:00
raucao a7b0b52a58 1.34.3
CI / Test (push) Successful in 1m0s
CI / Lint (push) Successful in 41s
2026-09-20 15:42:58 +02:00
raucao 12728f449b Merge pull request 'Add more place icons' (#109) from ui/place_icons into master
CI / Lint (push) Successful in 1m2s
CI / Test (push) Successful in 1m21s
Reviewed-on: #109
2026-09-20 13:41:33 +00:00
raucao fe5e85b029 Add fallback icon for buildings
CI / Test (pull_request) Successful in 59s
CI / Lint (pull_request) Successful in 42s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-09-20 15:35:20 +02:00
raucao c6a0b0da5e Add fallback icon for unlisted historic tags 2026-09-20 15:34:58 +02:00
raucao 553faa5edd Add fortress icon for historic monasteries 2026-09-20 15:34:44 +02:00
raucao dda0a9846d Add icons for cycleways and rail transit, prioritize historic places 2026-09-20 14:50:49 +02:00
raucao 4f423c8abc Use bridge icon for historic aqueducts 2026-09-20 14:50:07 +02:00
raucao 231a08c2f9 Add icons for landuse tags 2026-09-20 14:49:57 +02:00
raucao 7fccc56c92 Document place icon system 2026-09-20 14:49:25 +02:00
11 changed files with 400 additions and 20 deletions
+6 -2
View File
@@ -8,8 +8,11 @@
<br>
Marco (as in [Marco Polo][1]) is an unhosted maps application that respects
your privacy and choices. It allows users to connect their own remote storage
to sync place bookmarks across apps and devices.
your privacy and choices.
It allows users to connect their own remote storage to sync place bookmarks
across apps and devices. It also supports the decentralized sharing of place
photos (and soon reviews) via [Nostr][2].
This README outlines the details of collaborating on this Ember application, or
just building and deploying it for yourself.
@@ -79,3 +82,4 @@ To run the script, you need `imagemagick` and `librsvg` installed:
- [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/)
[1]: https://en.wikipedia.org/wiki/Marco_Polo
[2]: https://nostr.org
+103
View File
@@ -316,6 +316,70 @@ export default class PlaceDetails extends Component {
return this.formatMultiLine(val);
}
get operatorNames() {
const raw = this.tags.operator;
if (!raw) return null;
const values = raw
.split(';')
.map((value) => value.trim())
.filter(
(value) =>
value && value.toLowerCase() !== 'yes' && value.toLowerCase() !== 'no'
);
if (values.length === 0) return null;
return this.formatMultiLine(values.join(';'));
}
get operatorWebsite() {
const val = this.tags['operator:website'];
return val ? val.trim() : null;
}
get operatorWebsiteLabel() {
return this.operatorWebsite ? this.getDomain(this.operatorWebsite) : null;
}
get operatorWikidata() {
const val = this.tags['operator:wikidata'];
if (!val) return null;
return (
val
.split(';')
.map((v) => v.trim())
.filter(Boolean)[0] || null
);
}
get operatorWikipedia() {
const val = this.tags['operator:wikipedia'];
if (!val) return null;
return (
val
.split(';')
.map((v) => v.trim())
.filter(Boolean)[0] || null
);
}
get operatorWikipediaUrl() {
const val = this.operatorWikipedia;
if (!val) return null;
const match = val.match(/^([a-z-]+):(.+)$/i);
if (match) {
return `https://${match[1]}.wikipedia.org/wiki/${match[2]}`;
}
return `https://wikipedia.org/wiki/${val}`;
}
get showOperator() {
return !!(
this.operatorNames ||
this.operatorWebsite ||
this.operatorWikidata ||
this.operatorWikipedia
);
}
get cuisine() {
if (!this.tags.cuisine) return null;
return this.tags.cuisine
@@ -490,6 +554,45 @@ export default class PlaceDetails extends Component {
</p>
{{/if}}
{{#if this.showOperator}}
<p class="content-with-icon">
<Icon @name="briefcase" @title="Operator" />
<span>
{{this.operatorNames}}
{{#if this.operatorWebsite}}
<br />
<a
href={{this.operatorWebsite}}
target="_blank"
rel="noopener noreferrer"
>
{{this.operatorWebsiteLabel}}
</a>
{{/if}}
{{#if this.operatorWikidata}}
<br />
<a
href="https://www.wikidata.org/wiki/{{this.operatorWikidata}}"
target="_blank"
rel="noopener noreferrer"
>
Wikidata
</a>
{{/if}}
{{#if this.operatorWikipedia}}
<br />
<a
href={{this.operatorWikipediaUrl}}
target="_blank"
rel="noopener noreferrer"
>
Wikipedia
</a>
{{/if}}
</span>
</p>
{{/if}}
{{#if this.phone}}
<p class="content-with-icon">
<Icon @name="phone" @title="Phone" />
+14
View File
@@ -5,6 +5,7 @@
import activity from 'feather-icons/dist/icons/activity.svg?raw';
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
import briefcase from 'feather-icons/dist/icons/briefcase.svg?raw';
import featherCamera from 'feather-icons/dist/icons/camera.svg?raw';
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
@@ -53,6 +54,8 @@ import anchor from '@waysidemapping/pinhead/dist/icons/anchor.svg?raw';
import airportTerminal from '@waysidemapping/pinhead/dist/icons/airport_terminal.svg?raw';
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
import barrelWithBunghole from '@waysidemapping/pinhead/dist/icons/barrel_with_bunghole.svg?raw';
import bicycle from '@waysidemapping/pinhead/dist/icons/bicycle.svg?raw';
import bulldozer from '@waysidemapping/pinhead/dist/icons/bulldozer.svg?raw';
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
import banknote from '@waysidemapping/pinhead/dist/icons/banknote.svg?raw';
import banknoteInSlot from '@waysidemapping/pinhead/dist/icons/banknote_in_slot.svg?raw';
@@ -95,7 +98,9 @@ import fish from '@waysidemapping/pinhead/dist/icons/fish.svg?raw';
import flagCheckered from '@waysidemapping/pinhead/dist/icons/flag_checkered.svg?raw';
import flowerBouquet from '@waysidemapping/pinhead/dist/icons/flower_bouquet.svg?raw';
import fort from '@waysidemapping/pinhead/dist/icons/fort.svg?raw';
import fortress from '@waysidemapping/pinhead/dist/icons/fortress.svg?raw';
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
import gableRoofedBuilding from '@waysidemapping/pinhead/dist/icons/gable_roofed_building.svg?raw';
import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw';
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
@@ -139,6 +144,8 @@ import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?r
import tableTennisPaddle from '@waysidemapping/pinhead/dist/icons/table_tennis_paddle.svg?raw';
import tattooMachine from '@waysidemapping/pinhead/dist/icons/tattoo_machine.svg?raw';
import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
import townBuildings from '@waysidemapping/pinhead/dist/icons/town_buildings.svg?raw';
import transitVehicleOnRailwayTrack from '@waysidemapping/pinhead/dist/icons/transit_vehicle_on_railway_track.svg?raw';
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
@@ -164,6 +171,8 @@ const ICONS = {
'arrow-left': arrowLeft,
barbell,
'barrel-with-bunghole': barrelWithBunghole,
bicycle,
bulldozer,
banknote,
'banknote-in-slot': banknoteInSlot,
'badge-shield-with-fire': badgeShieldWithFire,
@@ -174,6 +183,7 @@ const ICONS = {
'boxing-glove-up': boxingGloveUp,
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
bridge,
briefcase,
bus,
camera,
'feather-camera': featherCamera,
@@ -216,6 +226,8 @@ const ICONS = {
'flower-bouquet': flowerBouquet,
'fork-and-knife': forkAndKnife,
fort,
fortress,
'gable-roofed-building': gableRoofedBuilding,
'gasoline-pump': gasolinePump,
gift,
globe,
@@ -284,6 +296,8 @@ const ICONS = {
target,
'trash-2': trash2,
'upload-cloud': uploadCloud,
'town-buildings': townBuildings,
'transit-vehicle-on-railway-track': transitVehicleOnRailwayTrack,
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
user,
'user-check': userCheck,
+27 -13
View File
@@ -99,18 +99,9 @@ export const POI_ICON_RULES = [
{ tags: { leisure: 'playground' }, icon: 'play-structure-with-slide' },
{ tags: { leisure: 'marina' }, icon: 'anchor' },
{ tags: { landuse: 'vineyard' }, icon: 'grapes' },
// Transport
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
{ tags: { aeroway: 'terminal' }, icon: 'airport-terminal' },
{ tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
{ tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
{ tags: { highway: 'bus_stop' }, icon: 'bus' },
{ tags: { bus: true }, icon: 'bus' },
{
tags: { railway: 'tram_stop' },
icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
},
{ tags: { landuse: 'cemetery' }, icon: 'memorial-stone-with-inscription' },
{ tags: { landuse: 'construction' }, icon: 'bulldozer' },
{ tags: { landuse: 'residential' }, icon: 'town-buildings' },
// Tourism
{ tags: { tourism: 'museum' }, icon: 'classical-building' },
@@ -131,9 +122,11 @@ export const POI_ICON_RULES = [
{ tags: { historic: 'canal' }, icon: 'winding_way_wide' },
{ tags: { historic: 'bridge' }, icon: 'bridge' },
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
{ tags: { historic: 'aqueduct' }, icon: 'bridge' },
{ tags: { historic: 'fort' }, icon: 'fort' },
{ tags: { historic: 'city_gate' }, icon: 'city-gate' },
{ tags: { historic: 'castle' }, icon: 'palace' },
{ tags: { historic: 'monastery' }, icon: 'fortress' },
{ tags: { building: 'tower', historic: 'yes' }, icon: 'castle-keep' },
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
@@ -153,7 +146,23 @@ export const POI_ICON_RULES = [
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
{ tags: { historic: 'ruins' }, icon: 'camera' },
{ tags: { historic: 'ruin' }, icon: 'camera' },
{ tags: { historic: 'yes' }, icon: 'camera' },
// Transport
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
{ tags: { aeroway: 'terminal' }, icon: 'airport-terminal' },
{ tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
{ tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
{ tags: { highway: 'bus_stop' }, icon: 'bus' },
{ tags: { highway: 'cycleway' }, icon: 'bicycle' },
{ tags: { bus: true }, icon: 'bus' },
{
tags: { railway: 'tram_stop' },
icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
},
{ tags: { light_rail: true }, icon: 'transit-vehicle-on-railway-track' },
{ tags: { train: true }, icon: 'transit-vehicle-on-railway-track' },
{ tags: { tram: true }, icon: 'transit-vehicle-on-railway-track' },
{ tags: { subway: true }, icon: 'transit-vehicle-on-railway-track' },
// Accommodation
{ tags: { tourism: 'hotel' }, icon: 'person-sleeping-in-bed' },
@@ -190,6 +199,7 @@ export const POI_ICON_RULES = [
{ tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' },
{ tags: { leisure: 'sports_centre' }, icon: 'person-running' },
{ tags: { leisure: 'pitch' }, icon: 'person-running' },
{ tags: { landuse: 'recreation_ground' }, icon: 'person-running' },
{ tags: { sport: true }, icon: 'person-running' },
// Healthcare
@@ -204,6 +214,10 @@ export const POI_ICON_RULES = [
{ tags: { building: 'commercial' }, icon: 'commercial-building' },
{ tags: { building: 'apartments' }, icon: 'lowrise-building' },
{ tags: { building: 'office' }, icon: 'lowrise-building' },
// Fallback
{ tags: { historic: true }, icon: 'camera' },
{ tags: { building: true }, icon: 'gable-roofed-building' },
];
/**
+137
View File
@@ -0,0 +1,137 @@
# Place Icons
How place/POI icons on the map are imported, selected from OSM tags, and rendered.
> Scope: this covers the **POI icon system** used for map markers and UI.
> It does **not** cover the PWA/launcher icons in `public/icons/` and `release/icons/`
> (those are app icons referenced from `index.html` / `web-app-manifest.json`).
## File map
| File | Responsibility |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `app/utils/icons.js` | Registry. Imports every SVG as a raw string (`?raw`), exposes `getIcon(name)` and `isIconFilled(name)`. |
| `app/utils/osm-icons.js` | OSM tag → icon mapping. `POI_ICON_RULES`, `getIconNameForTags(tags)`, `getIconSvgForTags(tags)`. |
| `app/components/icon.gjs` | Generic `<Icon @name="..." />` renderer used across the UI. |
| `app/components/map.gjs` | Map marker call site; embeds the icon SVG into an OpenLayers `Icon` data-URI. |
| `app/icons/` | Custom/local SVG files. |
| `node_modules/@waysidemapping/pinhead/dist/icons/*.svg` | Pinhead source icons (2,320 files). |
| `node_modules/feather-icons/dist/icons/*.svg` | Feather source icons. |
| `tests/unit/utils/osm-icons-test.js` | Tests for tag matching + integrity check that every rule icon exists. |
| `tests/integration/components/icon-test.gjs` | Tests for the `<Icon>` component. |
## How icons are imported (`app/utils/icons.js`)
Every icon is a **static named import** with Vite's `?raw` suffix, so the file contents
arrive as a string. There is **no glob/context import**; each icon is listed explicitly.
Imports are grouped and sorted alphabetically (per the header comment at
`app/utils/icons.js:1`):
1. **Feather icons**`feather-icons/dist/icons/*.svg`
2. **Pinhead icons**`@waysidemapping/pinhead/dist/icons/*.svg`
3. **Custom/local icons**`../icons/*.svg`
```js
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
import donut from '@waysidemapping/pinhead/dist/icons/donut.svg?raw';
import bitcoin from '../icons/bitcoin.svg?raw';
```
The imported strings are collected in the `ICONS` object, keyed by the kebab-case name
used everywhere else in the app. Quoted keys are needed when the name contains dashes:
```js
const ICONS = {
activity, // shorthand works for single-word names
'map-pin': mapPin, // quote dashed keys
donut,
'loading-ring': loadingRing,
};
```
Some registry keys are intentionally **not** normalized to kebab-case
(e.g. `climbing_wall`, `parking_p`, `winding_way_wide`) — match whatever string the
rule uses.
`FILLED_ICONS` lists names that render with `fill` instead of `stroke`
(`app/utils/icons.js:309`). `getIcon(name)` returns the SVG string (or `undefined`);
`isIconFilled(name)` drives the `.icon-filled` CSS class.
## How icons are selected from OSM tags (`app/utils/osm-icons.js`)
`POI_ICON_RULES` is an **ordered** array of `{ tags, icon }`. Each rule requires **all**
of its listed tags to match. The **first matching rule wins**, so more specific rules
must come before catch-alls.
```js
export const POI_ICON_RULES = [
{ tags: { cuisine: 'donut' }, icon: 'donut' },
...{ tags: { shop: true }, icon: 'shopping-bag' }, // catch-all, must be late
];
```
`getIconNameForTags(tags)` (`app/utils/osm-icons.js:214`) matching semantics:
- Returns `null` if `tags` is falsy or nothing matches.
- A rule tag only matches if its value is truthy.
- A tag value is split on `;` and trimmed, so `cuisine=donut;coffee_shop` matches both
`donut` and `coffee_shop` rules.
- `expectedValue: true` matches any non-empty value.
`getIconSvgForTags(tags)` wraps the name lookup and returns the raw SVG. It is exported
but currently unused.
## Rendering
- **UI:** `<Icon @name="donut" @size={{16}} />``app/components/icon.gjs`. Renders
only if `getIcon(@name)` returns an SVG. `@filled` overrides `isIconFilled`.
- **Map markers:** `searchResultStyle` in `app/components/map.gjs:123` calls
`getIconNameForTags(tags)` (line 151), strips the `<svg>` wrapper, and embeds the
paths in white inside the red pin at a `0.8` scale (lines 194-199). Results are cached
in `cachedIconUrls` keyed by icon name / `'default'`.
## Adding a new icon
1. **Find an SVG.**
- Pinhead: browse `node_modules/@waysidemapping/pinhead/dist/icons/` or search the
gallery at https://pinhead.ink. Filenames use snake_case (`coffee_bean.svg`).
- Feather: `node_modules/feather-icons/dist/icons/`.
- Otherwise drop a custom file in `app/icons/`.
2. **Import it** in `app/utils/icons.js` in the correct group, keeping the group
alphabetical, using `?raw`.
3. **Register it** in the `ICONS` map with a kebab-case key (quote it if it has dashes).
4. **Mark it filled** in `FILLED_ICONS` if it is a fill-style (not stroke-style) icon.
5. **Map OSM tags** in `POI_ICON_RULES` (`app/utils/osm-icons.js`), inserting the rule
before any catch-all that would otherwise shadow it.
No test is needed just to register a new icon or tag rule. The generic integrity test
(`all icons used in POI_ICON_RULES exist in the icons utility`) already checks every
rule's icon. Only add a focused test in `tests/unit/utils/osm-icons-test.js` for
non-trivial matching behavior (e.g. ordering/shadowing, semicolon-separated values, or
a new catch-all).
## Gotchas
- **Order matters.** First match wins; put specific rules before catch-alls
(`shop: true`, `office: true`, `craft: true`, `sport: true`, `historic: yes`).
- **The integrity test is your safety net.** `osm-icons-test.js` asserts every icon named
in `POI_ICON_RULES` exists in `getIcon`. Forgetting the import/registry entry fails CI.
This covers _all_ icons automatically, so you normally don't need to add a test when
adding one.
- **Naming mismatch.** Pinhead filenames are snake_case; registry keys are usually
kebab-case, with a few unnormalized exceptions. A typo yields `null` (no marker icon)
rather than an error.
- **Two icon systems.** Don't touch `public/icons/` (PWA icons) when working on POI icons;
`pnpm build:icons` regenerates those PNGs and is release-only.
- **Don't confuse with categories.** Tag→category matching for the sidebar/chips lives in
`app/utils/poi-category-matcher.js` and `app/utils/poi-categories.js`, separate from
tag→icon.
## Running the tests
```sh
pnpm test # builds and runs the full QUnit suite via Testem
pnpm lint # ESLint + Stylelint + Prettier + ember-template-lint
pnpm lint:fix # auto-fix
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.34.2",
"version": "1.34.4",
"private": true,
"description": "Unhosted maps app",
"repository": {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -42,7 +42,7 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-etcSle_0.js"></script>
<script type="module" crossorigin src="/assets/main-C9gFC_z2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bv3zmRKA.css">
</head>
<body>
@@ -457,4 +457,112 @@ module('Integration | Component | place-details', function (hooks) {
assert.dom('.place-payment-methods').doesNotExist();
});
test('it renders the operator name with a briefcase icon', async function (assert) {
const place = {
title: 'Operated Cafe',
osmTags: {
operator: 'Le Méridien',
},
};
await render(<template><PlaceDetails @place={{place}} /></template>);
const operatorBlock = this.element.querySelector(
'.meta-info .content-with-icon span.icon[title="Operator"]'
);
assert.ok(operatorBlock, 'Operator block is rendered');
const row = operatorBlock.closest('.content-with-icon');
assert.dom(row).includesText('Le Méridien');
});
test('it splits semicolon-separated operators onto separate lines', async function (assert) {
const place = {
title: 'Multi Operator Stop',
osmTags: {
operator: 'De Lijn;TEC',
},
};
await render(<template><PlaceDetails @place={{place}} /></template>);
const row = this.element
.querySelector('span.icon[title="Operator"]')
.closest('.content-with-icon');
assert.dom(row).includesText('De Lijn');
assert.dom(row).includesText('TEC');
assert.true(
row.querySelector('span:not(.icon)').innerHTML.includes('<br>'),
'Operators are separated by a line break'
);
});
test('it suppresses invalid operator values of yes and no', async function (assert) {
const place = {
title: 'Invalid Operator',
osmTags: {
operator: 'yes',
},
};
await render(<template><PlaceDetails @place={{place}} /></template>);
assert
.dom('span.icon[title="Operator"]')
.doesNotExist('Operator block is hidden for operator=yes');
});
test('it renders operator wikidata and website links', async function (assert) {
const place = {
title: 'Linked Operator',
osmTags: {
operator: 'Flixbus',
'operator:wikidata': 'Q15712278',
'operator:website': 'https://www.flixbus.com/',
'operator:wikipedia': 'en:FlixBus',
},
};
await render(<template><PlaceDetails @place={{place}} /></template>);
const row = this.element
.querySelector('span.icon[title="Operator"]')
.closest('.content-with-icon');
const wikidataLink = row.querySelector(
'a[href="https://www.wikidata.org/wiki/Q15712278"]'
);
assert.ok(wikidataLink, 'Wikidata link is rendered');
assert.dom(wikidataLink).hasText('Wikidata');
const websiteLink = row.querySelector('a[href="https://www.flixbus.com/"]');
assert.ok(websiteLink, 'Operator website link is rendered');
assert.dom(websiteLink).hasText('www.flixbus.com');
const wikipediaLink = row.querySelector(
'a[href="https://en.wikipedia.org/wiki/FlixBus"]'
);
assert.ok(wikipediaLink, 'Operator Wikipedia link is rendered');
assert.dom(wikipediaLink).hasText('Wikipedia');
});
test('it renders the operator block when only operator subkeys are present', async function (assert) {
const place = {
title: 'Subkey Only',
osmTags: {
'operator:wikidata': 'Q42',
},
};
await render(<template><PlaceDetails @place={{place}} /></template>);
assert
.dom('span.icon[title="Operator"]')
.exists('Operator block is rendered from subkeys alone');
assert
.dom('a[href="https://www.wikidata.org/wiki/Q42"]')
.exists('Wikidata link is rendered');
});
});