@sakuzu/maplibre-gl-draw 2.1.4 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,37 @@ the project follows semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.2.0] - 2026-10-10
10
+
11
+ Features can be written as GeoJSON without a drawing, a store that holds
12
+ only part of the document can keep the numbers of the automatic names,
13
+ and an image keeps a small format on a browser that cannot encode WebP.
14
+
15
+ ### Added in 2.2.0
16
+
17
+ - `featuresToGeoJSON(features, options)` and `featureToGeoJSON(feature,
18
+ options)` write features that need not be in a drawing with the rules of
19
+ `document.toGeoJSON`: rounding, ring orientation, the antimeridian, the
20
+ prefixed keys and the bbox. `ToGeoJSONOptions.getFile` reads the file of
21
+ an Image by its ID, and an Image whose file it does not find is written
22
+ without its pixels.
23
+ - `Store` gets two optional members, `getMaxNameNumber(type)` and
24
+ `recordNameNumber(type, number)`. A store that holds only part of the
25
+ document implements them so that a number used in the part it does not
26
+ hold is not used again for an automatic name. A store without them is
27
+ numbered as before.
28
+
29
+ ### Changed in 2.2.0
30
+
31
+ - `FileData.mimeType` of an image that was converted is the type the
32
+ browser returned, not the one requested
33
+ ([#31](https://github.com/sakuzu/maplibre-gl-draw/issues/31)).
34
+ - When the browser cannot encode WebP, an image with no transparent pixel
35
+ is encoded as JPEG (quality 0.92) instead of being kept as PNG, so a
36
+ photo stays small. An image with a transparent pixel keeps the format
37
+ the browser returns
38
+ ([#34](https://github.com/sakuzu/maplibre-gl-draw/issues/34)).
39
+
9
40
  ## [2.1.4] - 2026-10-07
10
41
 
11
42
  Two fixes to how sizes follow the zoom: the paths of a dataset that drew
@@ -1245,7 +1276,9 @@ available from Kasika, Inc.
1245
1276
  - A read-only mode and an interaction lock.
1246
1277
  - Plugins, custom modes and custom feature types.
1247
1278
 
1248
- [Unreleased]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.2...HEAD
1279
+ [Unreleased]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.2.0...HEAD
1280
+ [2.2.0]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.4...v2.2.0
1281
+ [2.1.4]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.3...v2.1.4
1249
1282
  [2.1.3]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.2...v2.1.3
1250
1283
  [2.1.2]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.1...v2.1.2
1251
1284
  [2.1.1]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.0...v2.1.1
package/README.ja.md CHANGED
@@ -99,7 +99,8 @@ GeoParquet や Arrow の表は、点、線、多角形が行ごとに混ざっ
99
99
  含めて保存でき、読み込めば同じ状態に戻ります。GeoJSON で書き出せば、
100
100
  ほかのツールと地物をやり取りできます。地物は GeoJSON の図形と
101
101
  GeoJSON の properties を持つので、ファイルはコードで読む地物と同じ形に
102
- なります。
102
+ なります。描画の外に持っている地物も、`featuresToGeoJSON` で同じ規則で
103
+ 書き出せます。
103
104
  変更は取引ごとに 1 つのイベントで、出どころを添えて届きます。文書を
104
105
  保存するストアは差し替えられます
105
106
  ([保存と読み込み](docs/guides/save-load.ja.md))。
package/README.md CHANGED
@@ -103,7 +103,8 @@ Write the document in the native format to keep layers, groups, styles
103
103
  and images, and load it to get the same state back. Write it as GeoJSON
104
104
  to exchange features with other tools: a feature holds a GeoJSON
105
105
  geometry and GeoJSON properties, so the file has the shape of the
106
- features you read in code.
106
+ features you read in code. Features kept outside a drawing are written
107
+ with the same rules by `featuresToGeoJSON`.
107
108
  Every change arrives as one event per transaction, with where it came
108
109
  from, and the store that holds the document can be replaced
109
110
  ([save and load](docs/guides/save-load.md)).
@@ -5,7 +5,7 @@
5
5
  * option of `createDraw`, for example to keep the document somewhere shared.
6
6
  */
7
7
  import type { DocumentChange } from '../events.js';
8
- import type { Feature, FileData, Group, Layer, Metadata } from '../model.js';
8
+ import type { Feature, FeatureType, FileData, Group, Layer, Metadata } from '../model.js';
9
9
  import type { Mode, Selection, SelectionType, VertexSelection } from '../state.js';
10
10
  /**
11
11
  * Where a write came from, as it arrives in {@link DocumentChange.source} and in the `source` of
@@ -187,4 +187,25 @@ export interface Store extends StoreView {
187
187
  setInteractionLock(value: boolean): void;
188
188
  /** Hides or shows an item in this client. */
189
189
  setLocallyHidden(id: string, hidden: boolean): void;
190
+ /**
191
+ * The largest number of a generated name ("Point 7") the Store knows to be used for this
192
+ * type: a feature type, `'Layer'` or `'Group'`. Optional.
193
+ *
194
+ * The instance numbers the names of new features, layers and groups from the names it can
195
+ * read through `listFeatures`, `listLayers` and `listGroups`. A Store that holds only part
196
+ * of the document can implement this method to give the numbers used in the part it does
197
+ * not hold, so that they are not used again: the next number is the one after the larger
198
+ * of the two. Return `undefined` when the Store knows nothing more than what it lists.
199
+ */
200
+ getMaxNameNumber?(type: FeatureType | 'Layer' | 'Group'): number | undefined;
201
+ /**
202
+ * Tells the Store the number of a name the instance has just generated for this type: a
203
+ * feature type, `'Layer'` or `'Group'`. Optional.
204
+ *
205
+ * It is called once per generated name, as the name is generated; the name may still not be
206
+ * written (a refused write leaves its number unused, as a deletion does). A Store that
207
+ * implements {@link Store.getMaxNameNumber} can use it to keep what that method returns up
208
+ * to date.
209
+ */
210
+ recordNameNumber?(type: FeatureType | 'Layer' | 'Group', number: number): void;
190
211
  }
@@ -8,6 +8,7 @@ export interface EmbeddedFileContent {
8
8
  *
9
9
  * @param dataURL - the image data URL found in the data
10
10
  * @param mimeType - the MIME type declared next to it
11
- * @returns the content to store, or null when it is not an accepted embedded image
11
+ * @returns the content to store, or null when it is not an accepted embedded image, or when
12
+ * it has to be scaled down and the browser returns the result in a type that is not accepted
12
13
  */
13
14
  export declare function normalizeEmbeddedFile(dataURL: unknown, mimeType: unknown): Promise<EmbeddedFileContent | null>;
@@ -9,13 +9,14 @@
9
9
  * texture limit is scaled down here, the same as when an image file is loaded.
10
10
  */
11
11
  import { readEmbeddedImage } from '../../../shared/utils/embedded-image.js';
12
- import { exceedsMaxImageSize, processImageDataUrl } from '../../../shared/utils/image.js';
12
+ import { exceedsMaxImageSize, processImageDataUrl, UnsupportedImageTypeError, } from '../../../shared/utils/image.js';
13
13
  /**
14
14
  * Validates and normalizes an embedded image
15
15
  *
16
16
  * @param dataURL - the image data URL found in the data
17
17
  * @param mimeType - the MIME type declared next to it
18
- * @returns the content to store, or null when it is not an accepted embedded image
18
+ * @returns the content to store, or null when it is not an accepted embedded image, or when
19
+ * it has to be scaled down and the browser returns the result in a type that is not accepted
19
20
  */
20
21
  export async function normalizeEmbeddedFile(dataURL, mimeType) {
21
22
  const info = readEmbeddedImage(dataURL);
@@ -24,7 +25,15 @@ export async function normalizeEmbeddedFile(dataURL, mimeType) {
24
25
  if (mimeType !== info.mimeType)
25
26
  return null;
26
27
  if (exceedsMaxImageSize(info.width, info.height)) {
27
- const processed = await processImageDataUrl(dataURL);
28
+ let processed;
29
+ try {
30
+ processed = await processImageDataUrl(dataURL);
31
+ }
32
+ catch (error) {
33
+ if (error instanceof UnsupportedImageTypeError)
34
+ return null;
35
+ throw error;
36
+ }
28
37
  return { mimeType: processed.mimeType, dataURL: processed.dataUrl };
29
38
  }
30
39
  return { mimeType: info.mimeType, dataURL };
@@ -1,5 +1,7 @@
1
1
  import type { Store } from '../../../store/store.js';
2
- import type { ExportOptions, Feature } from '../../../store/types.js';
2
+ import type { ExportOptions, Feature, FileData } from '../../../store/types.js';
3
+ /** Reads an embedded file by its ID (undefined when there is no such file) */
4
+ export type ReadFile = (id: string) => FileData | undefined;
3
5
  /**
4
6
  * Converts an internal Feature into a GeoJSON Feature
5
7
  *
@@ -10,10 +12,22 @@ import type { ExportOptions, Feature } from '../../../store/types.js';
10
12
  * properties with the maplibre-gl-draw: prefix
11
13
  * - the positions are rounded and the rings oriented (see the top of this file)
12
14
  * - a type that is not the type of its geometry is identified by featureType
13
- * - for an Image feature, the image data is embedded as Base64
15
+ * - for an Image feature, the image data is embedded as Base64, when `readFile` is given
16
+ * and finds its file
17
+ *
18
+ * @param readFile - Reads the file of an Image by its ID
14
19
  */
15
- export declare function convertFeatureToGeoJSON(feature: Feature, store: Store): GeoJSON.Feature<GeoJSON.Geometry> | null;
20
+ export declare function convertFeatureToGeoJSON(feature: Feature, readFile?: ReadFile): GeoJSON.Feature<GeoJSON.Geometry> | null;
16
21
  /**
17
22
  * Exports the current data as a GeoJSON FeatureCollection
18
23
  */
19
24
  export declare function exportGeoJSON(store: Store, options?: ExportOptions): GeoJSON.FeatureCollection<GeoJSON.Geometry>;
25
+ /**
26
+ * Converts internal Features into a GeoJSON FeatureCollection, in the order given
27
+ *
28
+ * A feature whose geometry cannot be exported is left out. The collection carries the bbox of
29
+ * every exported position, unless nothing was exported.
30
+ *
31
+ * @param readFile - Reads the file of an Image by its ID
32
+ */
33
+ export declare function convertFeaturesToGeoJSON(features: readonly Feature[], readFile?: ReadFile): GeoJSON.FeatureCollection<GeoJSON.Geometry>;
@@ -4,6 +4,10 @@
4
4
  * Conversion from the internal Feature to the GeoJSON format + export of a
5
5
  * GeoJSON FeatureCollection
6
6
  *
7
+ * The conversion is pure: it reads only the features it is given and, for the image of an
8
+ * Image, the function that reads an embedded file. The export of the document puts the
9
+ * features of the Store in their order and converts them with the same functions.
10
+ *
7
11
  * The output follows RFC 7946: every position is rounded to GEOJSON_COORDINATE_DECIMALS
8
12
  * decimal places, the rings of a polygon follow the right-hand rule (the outer ring
9
13
  * counter-clockwise, the holes clockwise) whatever orientation they were drawn in, every
@@ -107,9 +111,12 @@ function exportGeometry(geometry) {
107
111
  * properties with the maplibre-gl-draw: prefix
108
112
  * - the positions are rounded and the rings oriented (see the top of this file)
109
113
  * - a type that is not the type of its geometry is identified by featureType
110
- * - for an Image feature, the image data is embedded as Base64
114
+ * - for an Image feature, the image data is embedded as Base64, when `readFile` is given
115
+ * and finds its file
116
+ *
117
+ * @param readFile - Reads the file of an Image by its ID
111
118
  */
112
- export function convertFeatureToGeoJSON(feature, store) {
119
+ export function convertFeatureToGeoJSON(feature, readFile) {
113
120
  const properties = {};
114
121
  // Add the properties as they are (the values of this library already carry the prefix).
115
122
  // They are defined as own properties so that a `__proto__` key is written out instead of
@@ -120,7 +127,9 @@ export function convertFeatureToGeoJSON(feature, store) {
120
127
  // Add the metadata
121
128
  properties[`${DRAW_PROPERTY_PREFIX}id`] = feature.id;
122
129
  properties[`${DRAW_PROPERTY_PREFIX}layerId`] = feature.layerId;
123
- if (feature.groupId !== undefined) {
130
+ // A feature given from outside a drawing may carry `null` for no group, which is read as
131
+ // undefined at the boundary
132
+ if (feature.groupId != null) {
124
133
  properties[`${DRAW_PROPERTY_PREFIX}groupId`] = feature.groupId;
125
134
  }
126
135
  if (!feature.visible) {
@@ -130,7 +139,8 @@ export function convertFeatureToGeoJSON(feature, store) {
130
139
  properties[`${DRAW_PROPERTY_PREFIX}locked`] = true;
131
140
  }
132
141
  if (Object.keys(feature.style).length > 0) {
133
- properties[`${DRAW_PROPERTY_PREFIX}style`] = feature.style;
142
+ // A copy, so that changing the output does not change the feature given
143
+ properties[`${DRAW_PROPERTY_PREFIX}style`] = structuredClone(feature.style);
134
144
  }
135
145
  const geometry = exportGeometry(feature.geometry);
136
146
  if (!geometry) {
@@ -146,7 +156,7 @@ export function convertFeatureToGeoJSON(feature, store) {
146
156
  // Embed the image data of an Image as Base64
147
157
  if (feature.type === 'Image') {
148
158
  const imageFileId = getDrawProperty(feature, 'imageFileId');
149
- const fileData = imageFileId ? store.getFile(imageFileId) : undefined;
159
+ const fileData = imageFileId && readFile ? readFile(imageFileId) : undefined;
150
160
  if (fileData) {
151
161
  properties[`${DRAW_PROPERTY_PREFIX}imageData`] = fileData.dataURL;
152
162
  properties[`${DRAW_PROPERTY_PREFIX}imageMimeType`] = fileData.mimeType;
@@ -179,9 +189,20 @@ export function exportGeoJSON(store, options) {
179
189
  const layerIdSet = new Set(options.layerIds);
180
190
  features = features.filter((f) => layerIdSet.has(f.layerId));
181
191
  }
192
+ return convertFeaturesToGeoJSON(features, (id) => store.getFile(id));
193
+ }
194
+ /**
195
+ * Converts internal Features into a GeoJSON FeatureCollection, in the order given
196
+ *
197
+ * A feature whose geometry cannot be exported is left out. The collection carries the bbox of
198
+ * every exported position, unless nothing was exported.
199
+ *
200
+ * @param readFile - Reads the file of an Image by its ID
201
+ */
202
+ export function convertFeaturesToGeoJSON(features, readFile) {
182
203
  const geoJSONFeatures = [];
183
204
  for (const feature of features) {
184
- const geoFeature = convertFeatureToGeoJSON(feature, store);
205
+ const geoFeature = convertFeatureToGeoJSON(feature, readFile);
185
206
  if (geoFeature) {
186
207
  geoJSONFeatures.push(geoFeature);
187
208
  }
@@ -197,7 +197,10 @@ const RUNTIME_CHECKS = {
197
197
  }),
198
198
  isExternalEntry: func,
199
199
  };
200
- /** The members of the public Store the instance calls; `abortIntermediateUpdates` is optional */
200
+ /**
201
+ * The members of the public Store the instance calls; `abortIntermediateUpdates`,
202
+ * `getMaxNameNumber` and `recordNameNumber` are optional
203
+ */
201
204
  const STORE_METHODS = [
202
205
  'getFeature',
203
206
  'listFeatures',
@@ -485,3 +485,37 @@ export declare function deriveLegend(rule: StyleRule, messages?: Partial<Message
485
485
  * @returns `point` for points, `stroke` for lines and `fill` for areas
486
486
  */
487
487
  export declare function getStyleRuleChannel(type: FeatureType): 'point' | 'stroke' | 'fill';
488
+ /** Options of {@link featuresToGeoJSON} and {@link featureToGeoJSON}. */
489
+ export interface ToGeoJSONOptions {
490
+ /**
491
+ * Reads an embedded file by its ID, for the pixels of an Image: `(id) => store.getFile(id)`,
492
+ * for example. An Image whose file it does not find, or every Image when it is left out, is
493
+ * written without `maplibre-gl-draw:imageData` and `maplibre-gl-draw:imageMimeType`.
494
+ */
495
+ getFile?: (id: string) => FileData | undefined;
496
+ }
497
+ /**
498
+ * Writes features as a GeoJSON FeatureCollection, as `document.toGeoJSON` writes the
499
+ * document, without a drawing: the features need not be in one.
500
+ *
501
+ * The features come out in the order given (`document.toGeoJSON` gives the draw order, with
502
+ * the hidden features at the end). The rules of the output are those of `document.toGeoJSON`:
503
+ * positions rounded to 7 decimal places, rings by the right-hand rule, longitudes brought into
504
+ * -180 to 180, the values of the feature added to `properties` under the prefixed keys, and a
505
+ * `bbox` unless nothing was written. A feature whose geometry is a GeometryCollection is left
506
+ * out. The features given are not changed.
507
+ *
508
+ * @param features - The features, in the order to write them
509
+ * @param options - Where the files of the images are read from
510
+ * @returns The FeatureCollection
511
+ */
512
+ export declare function featuresToGeoJSON(features: readonly Feature[], options?: ToGeoJSONOptions): GeoJSONFeatureCollection;
513
+ /**
514
+ * Writes one feature as a GeoJSON Feature, as {@link featuresToGeoJSON} writes each of its
515
+ * features.
516
+ *
517
+ * @param feature - The feature
518
+ * @param options - Where the file of an image is read from
519
+ * @returns The Feature, or `null` when its geometry is a GeometryCollection
520
+ */
521
+ export declare function featureToGeoJSON(feature: Feature, options?: ToGeoJSONOptions): GeoJSONFeature | null;
package/dist/api/model.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-only
3
3
  import { isDrawProperty as isLibraryKey } from '../shared/properties.js';
4
4
  import { deriveLegend as deriveRuleLegend, evaluateStyleRule as evaluateRule, getStyleRuleChannel as getRuleChannel, } from '../view/style-rule.js';
5
+ import { convertFeaturesToGeoJSON, convertFeatureToGeoJSON, } from './impl/import-export/geojson-export.js';
5
6
  export { DRAW_PROPERTY_PREFIX } from '../shared/properties.js';
6
7
  /**
7
8
  * Whether a key of `properties` holds a value of the library, so that an attribute panel can
@@ -45,3 +46,32 @@ export function deriveLegend(rule, messages) {
45
46
  export function getStyleRuleChannel(type) {
46
47
  return getRuleChannel(type);
47
48
  }
49
+ /**
50
+ * Writes features as a GeoJSON FeatureCollection, as `document.toGeoJSON` writes the
51
+ * document, without a drawing: the features need not be in one.
52
+ *
53
+ * The features come out in the order given (`document.toGeoJSON` gives the draw order, with
54
+ * the hidden features at the end). The rules of the output are those of `document.toGeoJSON`:
55
+ * positions rounded to 7 decimal places, rings by the right-hand rule, longitudes brought into
56
+ * -180 to 180, the values of the feature added to `properties` under the prefixed keys, and a
57
+ * `bbox` unless nothing was written. A feature whose geometry is a GeometryCollection is left
58
+ * out. The features given are not changed.
59
+ *
60
+ * @param features - The features, in the order to write them
61
+ * @param options - Where the files of the images are read from
62
+ * @returns The FeatureCollection
63
+ */
64
+ export function featuresToGeoJSON(features, options) {
65
+ return convertFeaturesToGeoJSON(features, options?.getFile);
66
+ }
67
+ /**
68
+ * Writes one feature as a GeoJSON Feature, as {@link featuresToGeoJSON} writes each of its
69
+ * features.
70
+ *
71
+ * @param feature - The feature
72
+ * @param options - Where the file of an image is read from
73
+ * @returns The Feature, or `null` when its geometry is a GeometryCollection
74
+ */
75
+ export function featureToGeoJSON(feature, options) {
76
+ return convertFeatureToGeoJSON(feature, options?.getFile);
77
+ }
package/dist/index.d.ts CHANGED
@@ -70,3 +70,5 @@ export type { CompanionProvider, HandleProvider, Hit, SnapCandidate, SnapProvide
70
70
  export type { FeatureRenderer, FillRenderer, LineRenderer, OffsetUniforms, OverlayRenderer, PointRenderer, RenderContext, ShaderData, } from './api/extension/render.js';
71
71
  export type { ExtensionsCollections, FeatureTypesCollection, ModesCollection, OverlaysCollection, PluginsCollection, ProvidersCollection, } from './api/extensions.js';
72
72
  export { deriveLegend, evaluateStyleRule, getStyleRuleChannel } from './api/model.js';
73
+ export type { ToGeoJSONOptions } from './api/model.js';
74
+ export { featuresToGeoJSON, featureToGeoJSON } from './api/model.js';
package/dist/index.js CHANGED
@@ -6,3 +6,4 @@ export { MODES } from './api/state.js';
6
6
  export { DrawError } from './api/errors.js';
7
7
  // Style rule functions
8
8
  export { deriveLegend, evaluateStyleRule, getStyleRuleChannel } from './api/model.js';
9
+ export { featuresToGeoJSON, featureToGeoJSON } from './api/model.js';
@@ -1,22 +1,31 @@
1
- /**
2
- * Image processing utilities
3
- *
4
- * Converts an image file into the WebP format, resizing it when necessary.
5
- */
6
1
  export interface ProcessedImage {
7
2
  dataUrl: string;
8
3
  width: number;
9
4
  height: number;
10
5
  mimeType: string;
11
6
  }
7
+ /**
8
+ * The error thrown when the canvas returns an image of a type the library does not accept
9
+ *
10
+ * A browser that cannot encode the requested type returns another one (usually PNG); one that
11
+ * is not PNG / JPEG / WebP / GIF cannot be stored as an embedded image.
12
+ */
13
+ export declare class UnsupportedImageTypeError extends Error {
14
+ constructor(mimeType: string);
15
+ }
12
16
  /**
13
17
  * Maximum size of an image
14
18
  * A value that accounts for the WebGL texture limit (modern GPUs support 4096 or more)
15
19
  */
16
20
  export declare const MAX_IMAGE_SIZE = 4096;
17
21
  /**
18
- * Converts a file into a DataURL in the WebP format
22
+ * Converts a file into a DataURL in the WebP format. When the browser cannot encode WebP, an image
23
+ * with no transparent pixel is encoded as JPEG, and one with a transparent pixel keeps the format
24
+ * the browser returns (such as PNG)
19
25
  * A large image is resized automatically
26
+ *
27
+ * @throws UnsupportedImageTypeError when the browser returns a type other than PNG / JPEG /
28
+ * WebP / GIF
20
29
  */
21
30
  export declare function processImageFile(file: File): Promise<ProcessedImage>;
22
31
  /**
@@ -24,7 +33,12 @@ export declare function processImageFile(file: File): Promise<ProcessedImage>;
24
33
  */
25
34
  export declare function exceedsMaxImageSize(width: number, height: number): boolean;
26
35
  /**
27
- * Converts an image DataURL into a DataURL in the WebP format
36
+ * Converts an image DataURL into a DataURL in the WebP format. When the browser cannot encode
37
+ * WebP, an image with no transparent pixel is encoded as JPEG, and one with a transparent pixel
38
+ * keeps the format the browser returns (such as PNG)
28
39
  * A large image is resized automatically
40
+ *
41
+ * @throws UnsupportedImageTypeError when the browser returns a type other than PNG / JPEG /
42
+ * WebP / GIF
29
43
  */
30
44
  export declare function processImageDataUrl(originalDataUrl: string): Promise<ProcessedImage>;
@@ -1,6 +1,42 @@
1
1
  // SPDX-FileCopyrightText: 2026 SAKAIDA Atsushi
2
2
  // SPDX-License-Identifier: AGPL-3.0-only
3
+ /**
4
+ * Image processing utilities
5
+ *
6
+ * Converts an image file into the WebP format, resizing it when necessary. A browser that cannot
7
+ * encode WebP from a canvas returns another format (such as PNG). The image is then encoded as JPEG
8
+ * when none of its pixels is transparent, and otherwise keeps the format the browser returned.
9
+ */
10
+ import { EMBEDDED_IMAGE_MIME_TYPES } from './embedded-image.js';
3
11
  const WEBP_QUALITY = 0.95;
12
+ /** The quality of the JPEG an opaque image is encoded as when the browser cannot encode WebP */
13
+ const JPEG_QUALITY = 0.92;
14
+ /**
15
+ * The error thrown when the canvas returns an image of a type the library does not accept
16
+ *
17
+ * A browser that cannot encode the requested type returns another one (usually PNG); one that
18
+ * is not PNG / JPEG / WebP / GIF cannot be stored as an embedded image.
19
+ */
20
+ export class UnsupportedImageTypeError extends Error {
21
+ constructor(mimeType) {
22
+ super(`The browser returned an image of an unsupported type: ${mimeType || '(none)'}`);
23
+ this.name = 'UnsupportedImageTypeError';
24
+ }
25
+ }
26
+ /** The type in the `data:<type>;` or `data:<type>,` prefix of a data URL */
27
+ const DATA_URL_TYPE = /^data:([^;,]*)[;,]/;
28
+ /**
29
+ * The type of the image a data URL holds, read from its prefix
30
+ *
31
+ * @throws UnsupportedImageTypeError when the type is not PNG / JPEG / WebP / GIF
32
+ */
33
+ function acceptedImageType(dataUrl) {
34
+ const mimeType = DATA_URL_TYPE.exec(dataUrl)?.[1] ?? '';
35
+ if (!EMBEDDED_IMAGE_MIME_TYPES.includes(mimeType)) {
36
+ throw new UnsupportedImageTypeError(mimeType);
37
+ }
38
+ return mimeType;
39
+ }
4
40
  /**
5
41
  * Maximum size of an image
6
42
  * A value that accounts for the WebGL texture limit (modern GPUs support 4096 or more)
@@ -27,8 +63,13 @@ function calculateResizedDimensions(width, height, maxSize) {
27
63
  return { width: newWidth, height: newHeight, needsResize: true };
28
64
  }
29
65
  /**
30
- * Converts a file into a DataURL in the WebP format
66
+ * Converts a file into a DataURL in the WebP format. When the browser cannot encode WebP, an image
67
+ * with no transparent pixel is encoded as JPEG, and one with a transparent pixel keeps the format
68
+ * the browser returns (such as PNG)
31
69
  * A large image is resized automatically
70
+ *
71
+ * @throws UnsupportedImageTypeError when the browser returns a type other than PNG / JPEG /
72
+ * WebP / GIF
32
73
  */
33
74
  export async function processImageFile(file) {
34
75
  return processImageDataUrl(await fileToDataUrl(file));
@@ -40,8 +81,13 @@ export function exceedsMaxImageSize(width, height) {
40
81
  return width > MAX_IMAGE_SIZE || height > MAX_IMAGE_SIZE;
41
82
  }
42
83
  /**
43
- * Converts an image DataURL into a DataURL in the WebP format
84
+ * Converts an image DataURL into a DataURL in the WebP format. When the browser cannot encode
85
+ * WebP, an image with no transparent pixel is encoded as JPEG, and one with a transparent pixel
86
+ * keeps the format the browser returns (such as PNG)
44
87
  * A large image is resized automatically
88
+ *
89
+ * @throws UnsupportedImageTypeError when the browser returns a type other than PNG / JPEG /
90
+ * WebP / GIF
45
91
  */
46
92
  export async function processImageDataUrl(originalDataUrl) {
47
93
  // 1. Load it as an Image (to get the dimensions)
@@ -60,15 +106,36 @@ export async function processImageDataUrl(originalDataUrl) {
60
106
  ctx.imageSmoothingEnabled = true;
61
107
  ctx.imageSmoothingQuality = 'high';
62
108
  ctx.drawImage(img, 0, 0, width, height);
63
- // 4. Convert into WebP
64
- const webpDataUrl = canvas.toDataURL('image/webp', WEBP_QUALITY);
109
+ // 4. Convert into WebP. A browser that cannot encode WebP returns another type, so the type
110
+ // is read from the data URL it returned rather than taken from the request
111
+ let dataUrl = canvas.toDataURL('image/webp', WEBP_QUALITY);
112
+ let mimeType = acceptedImageType(dataUrl);
113
+ // 5. When the browser cannot encode WebP, an opaque image is encoded as JPEG, which is far
114
+ // smaller than PNG for a photo. An image with a transparent pixel keeps the returned type,
115
+ // because JPEG would lose the transparency
116
+ if (mimeType !== 'image/webp' && !hasTransparentPixel(ctx, width, height)) {
117
+ dataUrl = canvas.toDataURL('image/jpeg', JPEG_QUALITY);
118
+ mimeType = acceptedImageType(dataUrl);
119
+ }
65
120
  return {
66
- dataUrl: webpDataUrl,
121
+ dataUrl,
67
122
  width,
68
123
  height,
69
- mimeType: 'image/webp',
124
+ mimeType,
70
125
  };
71
126
  }
127
+ /**
128
+ * Whether any pixel drawn on the canvas is not fully opaque (alpha below 255)
129
+ * Stops at the first such pixel
130
+ */
131
+ function hasTransparentPixel(ctx, width, height) {
132
+ const { data } = ctx.getImageData(0, 0, width, height);
133
+ for (let i = 3; i < data.length; i += 4) {
134
+ if (data[i] < 255)
135
+ return true;
136
+ }
137
+ return false;
138
+ }
72
139
  /**
73
140
  * Converts a File into a DataURL
74
141
  */
@@ -75,6 +75,11 @@ export declare function normalizeAutoNameConfig(config: AutoNameConfig | boolean
75
75
  * any generation happened is also recorded (with the old approach it could be reused after the
76
76
  * deletion). This is an extension of the "a number used once is not reused" policy, and it can
77
77
  * only work in the direction of increasing the gaps.
78
+ *
79
+ * A Store that holds only part of the document can give, per type, the largest number it
80
+ * knows to be used (`getMaxNameNumber`): each generation then takes the number after the
81
+ * larger of that and the count above, and tells the Store the number it took
82
+ * (`recordNameNumber`). With a Store that has neither, the numbering is the count above alone.
78
83
  */
79
84
  export declare class AutoNameGenerator {
80
85
  private readonly store;
@@ -90,6 +95,8 @@ export declare class AutoNameGenerator {
90
95
  listLayers(): Layer[];
91
96
  listGroups(): Group[];
92
97
  subscribe(listener: (changes: StoreChange) => void): () => void;
98
+ getMaxNameNumber?(type: AutoNameType): number | undefined;
99
+ recordNameNumber?(type: AutoNameType, number: number): void;
93
100
  }, config?: AutoNameConfig | boolean);
94
101
  /**
95
102
  * Returns whether it is enabled
@@ -123,6 +130,11 @@ export declare class AutoNameGenerator {
123
130
  * not reused and a gap appears.
124
131
  */
125
132
  private findNextNumber;
133
+ /**
134
+ * Takes the next number of a counter key: the one after the larger of the counter and the
135
+ * largest number the Store knows (when it gives one), and tells the Store the number taken
136
+ */
137
+ private takeNextNumber;
126
138
  /**
127
139
  * Takes in the largest existing number only the first time a generation happens for that
128
140
  * counter key
@@ -66,6 +66,11 @@ export function normalizeAutoNameConfig(config) {
66
66
  * any generation happened is also recorded (with the old approach it could be reused after the
67
67
  * deletion). This is an extension of the "a number used once is not reused" policy, and it can
68
68
  * only work in the direction of increasing the gaps.
69
+ *
70
+ * A Store that holds only part of the document can give, per type, the largest number it
71
+ * knows to be used (`getMaxNameNumber`): each generation then takes the number after the
72
+ * larger of that and the count above, and tells the Store the number it took
73
+ * (`recordNameNumber`). With a Store that has neither, the numbering is the count above alone.
69
74
  */
70
75
  export class AutoNameGenerator {
71
76
  constructor(store, config = true) {
@@ -175,8 +180,20 @@ export class AutoNameGenerator {
175
180
  */
176
181
  findNextNumber(featureType, typeName) {
177
182
  this.scanOnce(featureType, () => this.getMaxNumberFromFeatures(featureType, typeName));
178
- const nextNumber = (this.counters.get(featureType) ?? 0) + 1;
179
- this.counters.set(featureType, nextNumber);
183
+ return this.takeNextNumber(featureType);
184
+ }
185
+ /**
186
+ * Takes the next number of a counter key: the one after the larger of the counter and the
187
+ * largest number the Store knows (when it gives one), and tells the Store the number taken
188
+ */
189
+ takeNextNumber(counterKey) {
190
+ const known = this.store.getMaxNameNumber?.(counterKey);
191
+ if (known !== undefined && Number.isSafeInteger(known)) {
192
+ this.recordNumber(counterKey, known);
193
+ }
194
+ const nextNumber = (this.counters.get(counterKey) ?? 0) + 1;
195
+ this.counters.set(counterKey, nextNumber);
196
+ this.store.recordNameNumber?.(counterKey, nextNumber);
180
197
  return nextNumber;
181
198
  }
182
199
  /**
@@ -312,9 +329,7 @@ export class AutoNameGenerator {
312
329
  }
313
330
  return maxNumber;
314
331
  });
315
- const nextNumber = (this.counters.get(LAYER_COUNTER_KEY) ?? 0) + 1;
316
- this.counters.set(LAYER_COUNTER_KEY, nextNumber);
317
- return nextNumber;
332
+ return this.takeNextNumber(LAYER_COUNTER_KEY);
318
333
  }
319
334
  /**
320
335
  * Generates the name of a new Group
@@ -347,8 +362,6 @@ export class AutoNameGenerator {
347
362
  }
348
363
  return maxNumber;
349
364
  });
350
- const nextNumber = (this.counters.get(GROUP_COUNTER_KEY) ?? 0) + 1;
351
- this.counters.set(GROUP_COUNTER_KEY, nextNumber);
352
- return nextNumber;
365
+ return this.takeNextNumber(GROUP_COUNTER_KEY);
353
366
  }
354
367
  }
@@ -162,6 +162,15 @@ export class DrawStore {
162
162
  return __classPrivateFieldGet(this, _DrawStore_instances, "m", _DrawStore_write).call(this, () => __classPrivateFieldGet(this, _DrawStore_contract, "f").setMetadata(metadata));
163
163
  }
164
164
  // ============================================================================
165
+ // The numbers of generated names (optional members of the contract)
166
+ // ============================================================================
167
+ getMaxNameNumber(type) {
168
+ return __classPrivateFieldGet(this, _DrawStore_contract, "f").getMaxNameNumber?.(type);
169
+ }
170
+ recordNameNumber(type, number) {
171
+ __classPrivateFieldGet(this, _DrawStore_contract, "f").recordNameNumber?.(type, number);
172
+ }
173
+ // ============================================================================
165
174
  // The state of this client (kept by the contract)
166
175
  // ============================================================================
167
176
  getSelection() {
@@ -19,7 +19,7 @@
19
19
  * DocumentStore is the document half the in-memory contract store is built on.
20
20
  */
21
21
  import type { Store as PublicStore } from '../api/extension/store.js';
22
- import type { BoxSelection, DragState, Feature, FileData, Group, Layer, Metadata, Mode, Selection, SelectionType, StoreChange, TentativeState, UpdateFeatureOptions, UpdateSource, VertexSelection } from './types.js';
22
+ import type { BoxSelection, DragState, Feature, FeatureType, FileData, Group, Layer, Metadata, Mode, Selection, SelectionType, StoreChange, TentativeState, UpdateFeatureOptions, UpdateSource, VertexSelection } from './types.js';
23
23
  /**
24
24
  * The Store a host gives with the `store` option: the public `Store`, with the rules core
25
25
  * relies on
@@ -373,4 +373,11 @@ export interface Store extends StoreView, UiState {
373
373
  deleteFile(id: string): boolean;
374
374
  /** Sets the metadata, merging (see {@link DocumentStore.setMetadata}) */
375
375
  setMetadata(metadata: Partial<Metadata>): boolean;
376
+ /**
377
+ * The largest number of a generated name the Store knows to be used for this type, beyond
378
+ * what it lists, or undefined (see the public `Store.getMaxNameNumber`)
379
+ */
380
+ getMaxNameNumber?(type: FeatureType | 'Layer' | 'Group'): number | undefined;
381
+ /** Tells the Store the number of a name just generated (see the public `Store.recordNameNumber`) */
382
+ recordNameNumber?(type: FeatureType | 'Layer' | 'Group', number: number): void;
376
383
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sakuzu/maplibre-gl-draw",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
4
4
  "description": "WebGL2 drawing and editing engine for MapLibre GL JS: points, lines, and polygons rendered in a custom layer, with selection, hit testing, and geometry utilities.",
5
5
  "keywords": [
6
6
  "maplibre",