Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 28 additions & 24 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@
"leaflet": "^1.9.4",
"leaflet.locatecontrol": "^0.81.1",
"mapml-extension": "git+https://github.com/Maps4HTML/mapml-extension",
"media-query-parser": "^3.0.2",
"media-query-solver": "^0.1.3",
"media-query-parser": "file:vendor/media-query-parser",
"media-query-solver": "file:vendor/media-query-solver",
"path": "^0.12.7",
"playwright": "^1.39.0",
"proj4": "^2.6.2",
Expand Down
16 changes: 15 additions & 1 deletion src/map-tile.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,21 @@ export class HTMLTileElement extends HTMLElement {

// If this was the last tile in the layer, clean up the layer
if (this._tileLayer._mapTiles && this._tileLayer._mapTiles.length === 0) {
this._tileLayer.remove();
// _createOrGetTileLayer added this MapTileLayer to a LayerGroup, so it
// has to be removed from that group. Layer.remove() only detaches it
// from the map - and does nothing at all while the group itself is off
// the map (e.g. the layer is unchecked), because the layer has no
// _map. Either way the group keeps its reference, and LayerGroup.onAdd
// re-adds every layer it holds, so each dead MapTileLayer reappears -
// container, stale tiles and all - the next time the layer is checked.
const parentLayerGroup = this._parentEl._templatedLayer?.removeLayer
? this._parentEl._templatedLayer
: this._parentEl._layer;
if (parentLayerGroup?.removeLayer) {
parentLayerGroup.removeLayer(this._tileLayer);
} else {
this._tileLayer.remove();
}
this._tileLayer = null;
delete this._tileLayer;
}
Expand Down
14 changes: 12 additions & 2 deletions src/mapml/layers/MapTileLayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,18 @@ export var MapTileLayer = GridLayer.extend({
onRemove: function (map) {
// Clean up pending tiles
this._pendingTiles = {};
// remove _container from the dom, but don't delete it
DomUtil.remove(this._container);
const container = this._container;
// GridLayer.onRemove discards the rendered tiles and releases the zoom
// limits this layer registered with the map. Skipping it leaves the
// previously rendered tiles inside the detached container, so when the
// layer is added back (e.g. its map-layer is checked again) onAdd
// re-appends them and GridLayer.onAdd renders a second copy alongside.
GridLayer.prototype.onRemove.call(this, map);
// GridLayer.onRemove nulls _container, but onAdd re-appends this one
// rather than creating another, so put it back, emptied of the levels
// GridLayer.onAdd would otherwise orphan inside it.
container.textContent = '';
this._container = container;
},
setZIndex: function (zIndex) {
this.options.zIndex = zIndex;
Expand Down
70 changes: 70 additions & 0 deletions test/e2e/elements/map-tile/map-tile.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,76 @@ test.describe('Map Tile Dynamic Updates Tests', () => {
expect(finalState.tile2Src).toBe('tiles/green-tile.png');
});

test('tile content replaced while a layer is unchecked is not restored when it is checked again', async () => {
// Regression test for a <map-layer> whose <map-tile> children are
// generated, as a script rendering tiles for the current view does.
// Replacing them while the layer is unchecked used to leave the discarded
// tiles attached to the map, so checking the layer again drew them again
// alongside the current ones - two generations of content at once, and a
// doubled copy of the current generation.
await page.reload();
await page.waitForTimeout(1000);

const viewer = page.getByTestId('viewer');
const renderedTileSources = () =>
viewer.evaluate((v) => {
const sources = {};
for (const img of v.shadowRoot.querySelectorAll('img')) {
// ignore the map's own control imagery, count only tile images
if (!img.currentSrc.endsWith('-tile.png')) continue;
const name = img.currentSrc.split('/').pop();
sources[name] = (sources[name] || 0) + 1;
}
return sources;
});

// the fixture starts out showing green tiles
expect((await renderedTileSources())['green-tile.png']).toBeGreaterThan(0);

await viewer.evaluate((v) =>
v.querySelector('map-layer').removeAttribute('checked')
);
await page.waitForTimeout(500);
expect(await renderedTileSources()).toEqual({});

// While the layer is hidden, replace every tile with a new set drawn from
// a different image, the way a generator regenerates content for a view
// that changed while the layer was switched off.
await viewer.evaluate((v) => {
const layer = v.querySelector('map-layer');
layer.querySelectorAll('map-tile').forEach((tile) => tile.remove());
for (const [col, row] of [
[9, 10],
[9, 11],
[10, 10],
[10, 11]
]) {
const tile = document.createElement('map-tile');
tile.setAttribute('zoom', '2');
tile.setAttribute('col', String(col));
tile.setAttribute('row', String(row));
tile.setAttribute('src', 'tiles/red-tile.png');
layer.appendChild(tile);
}
});
await page.waitForTimeout(500);
// nothing is drawn while the layer is unchecked
expect(await renderedTileSources()).toEqual({});

await viewer.evaluate((v) =>
v.querySelector('map-layer').setAttribute('checked', '')
);
await page.waitForTimeout(1000);

// only the tiles that are still in the DOM may be drawn, each once
const afterToggle = await renderedTileSources();
expect(afterToggle['green-tile.png']).toBeUndefined();
expect(afterToggle['red-tile.png']).toBeGreaterThan(0);
expect(afterToggle['red-tile.png']).toBeLessThanOrEqual(
await viewer.evaluate((v) => v.querySelectorAll('map-tile').length)
);
});

test.afterAll(async () => {
await context.close();
});
Expand Down
20 changes: 20 additions & 0 deletions vendor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Vendored dependencies

These packages are build-time (dev) dependencies that are bundled into
`dist/` by Rollup. They are kept here because `media-query-solver` was
unpublished from the npm registry on 2026-09-30, which broke `npm ci`.
`media-query-parser` is by the same author and is vendored pre-emptively.

| Package | Version | Original npm tarball integrity | Source |
|---|---|---|---|
| `media-query-parser` | 3.0.2 | `sha512-3WLXSFQZVuo1d9xpt72Ul0khXy72qgtz1KqDBO6KbmyvMsBsQRcePhYYR2U5QBSOz/u5pb5zj2s2FmgJe8jaTw==` | [prushforth/media-query-parser](https://github.com/prushforth/media-query-parser) (fork of `tmgldn/media-query-parser`) |
| `media-query-solver` | 0.1.3 | `sha512-OkL4tpvexcAOG09rNdbAO1MebJzRa2s0RK7hTO9gVAL1jefbm6rLG9jcRYbwO1M3N8UGhHymMtOXK2ciDy3r8w==` | [prushforth/media-query-solver](https://github.com/prushforth/media-query-solver) (fork of `tmgldn/media-query-solver`) |

Each directory is the unmodified contents of the published npm tarball
(verified against the integrity hashes above), except that the
development-only fields (`scripts`, `devDependencies`, `simple-git-hooks`,
`prettier`, `lint-time`, `jest`) were removed from `package.json`. Without
that, npm would install the packages' own dev tooling and run their
`prepare` script when resolving the `file:` dependencies.

Both packages are MIT licensed; see the `LICENSE` file in each directory, and the entry in the MapML.js LICENSE.md file.
18 changes: 18 additions & 0 deletions vendor/media-query-parser/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
MIT License

Copyright (c) 2023 Tom Golden <oss@tom.bio>

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
132 changes: 132 additions & 0 deletions vendor/media-query-parser/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# `media-query-parser`

[![npm](https://img.shields.io/npm/v/media-query-parser)](https://www.npmjs.com/package/media-query-parser)
[![npm type definitions](https://img.shields.io/npm/types/media-query-parser)](#)
[![license](https://img.shields.io/npm/l/media-query-parser)](#)
[![npm downloads](https://img.shields.io/npm/dw/media-query-parser)](https://www.npmjs.com/package/media-query-parser)
[![install size](https://packagephobia.com/badge?p=media-query-parser)](https://packagephobia.com/result?p=media-query-parser)

- **Create a JS object from a CSS media queries**
- **Create a CSS media query from a JS object**
- **Returns a ParserError for invalid CSS media queries**
- **Spec-compliant** - https://www.w3.org/TR/mediaqueries-5/
- **All valid queries parsed
e.g. `(100px < width < 200px)`**
- **Zero-dependencies**
- **Well tested** - every single line
- **TypeScript friendly**

[![demo](demo.png)](https://tbjgolden.github.io/media-query-parser/playground/)
[Try it!](https://tbjgolden.github.io/media-query-parser/playground/)

## Why?

Other CSS parsers (e.g. css-tree and postcss) do not support all media query syntax out of the box.

Further, the only other media query parser that I'm aware of is `postcss-media-query-parser` - which
is specific to postcss and doesn't parse newer syntax like range expressions (i.e.
`(width >= 768px)`).

This package is a spec-compliant media query parser that can be used in Node/Deno/etc, or on the
client that precisely matches the spec right down to the quirks.

These are valid media queries that this library supports:

```css
@media (768px <= width < 1200px);
@media only print and (color);
@media not (not (not (((hover) or ((not (color)))))));
@media (🐈: 😸 /* if cat happy */) {
/* this query has valid syntax, but is clearly not a real feature 😿 */
/* For extensions to this project, check out "Libraries that use this" at the bottom of this README */
}
```

These are invalid media queries that this library will detect:

```css
@media (color) or (hover); /* or cannot be at top level */
@media (min-width: calc(50vw + 10px)); /* functions aren't valid values */
@media not((color)); /* whitespace must follow `not` */
@media (768px < = width < 1200px); /* cannot have a space between `<` and `=` */
```

## Install

This package is available from the `npm` registry.

```sh
npm install media-query-parser
```

## Usage

Supports JavaScript + TypeScript:

```ts
import { parseMediaQuery } from "media-query-parser";

const mediaQuery = parseMediaQuery("screen and (min-width: 768px)");
if (!isParserError(mediaQuery)) {
console.log(mediaQuery);
// {
// _t: "query",
// type: "screen",
// condition: {
// _t: "condition",
// op: "and",
// nodes: [
// {
// _t: "in-parens",
// node: {
// _t: "feature",
// context: "value",
// feature: "min-width",
// value: {
// _t: "dimension",
// value: 768,
// unit: "px",
// },
// },
// },
// ],
// },
// }
// // start/end omitted for brevity

console.log(stringify(mediaQuery.condition));
// "(min-width: 768px)"
}
```

Can also be imported via `require("media-query-parser")`.

### [**v3 (Current) Docs**](https://tbjgolden.github.io/media-query-parser/)

###### [_v2 API docs_](https://github.com/tbjgolden/media-query-parser/tree/v2.0.2/docs/api#functions)

## Libraries that use this

- (See GitHub for dynamically updated list)

## Node versions

This source code of this library supports `node >=6.5.0` via `require()` and
`node >=16.0.0 || ^14.13.1` via import. (Below v6.5.0 is possible but you'd need to transpile the
CommonJS code from ES6 yourself)

## Contributing

- PRs welcome and accepted, simply fork and create
- Issues also very welcome
- Treat others with common courtesy and respect 🤝

Dev environment (for contributing) requires:

- node >= 16.14.0
- npm >= 6.8.0
- git >= 2.11

## Licence

MIT
18 changes: 18 additions & 0 deletions vendor/media-query-parser/dist/cjs/ast/ast.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { BooleanFeatureNode, ConditionNode, ConditionWithoutOrNode, FeatureNode, GeneralEnclosedNode, InParensNode, ParserToken, PlainFeatureNode, QueryListNode, QueryNode } from "../utils.js";
export type Match<T> = {
t: T;
i: number;
} | undefined;
export declare const matchPlain: (ts: ParserToken[], i?: number) => Match<PlainFeatureNode>;
export declare const matchBoolean: (ts: ParserToken[], i?: number) => Match<BooleanFeatureNode>;
export declare const matchFeature: (ts: ParserToken[], i?: number) => Match<FeatureNode>;
export declare const matchGeneralEnclosed: (ts: ParserToken[], i?: number) => Match<GeneralEnclosedNode>;
export declare const matchInParens: (ts: ParserToken[], i?: number) => Match<InParensNode>;
export declare const matchOr: (ts: ParserToken[], i?: number) => Match<InParensNode>;
export declare const matchAnd: (ts: ParserToken[], i?: number) => Match<InParensNode>;
export declare const matchNot: (ts: ParserToken[], i?: number) => Match<InParensNode>;
export declare const matchCondition: (ts: ParserToken[], i?: number) => Match<ConditionNode>;
export declare const matchConditionWithoutOr: (ts: ParserToken[], i?: number) => Match<ConditionWithoutOrNode>;
export declare const matchQuery: (ts: ParserToken[]) => Match<QueryNode>;
export declare const matchQueryList: (parsingTokens: ParserToken[]) => QueryListNode;
export declare const splitMediaQueryList: (tokens: ParserToken[]) => Array<ParserToken[]>;
Loading
Loading