From 2359e18cf0b5f491e9fc98fbdda348e37b3a5bce Mon Sep 17 00:00:00 2001 From: Ihor Dykhta Date: Sun, 31 May 2026 22:44:10 +0300 Subject: [PATCH 1/3] fix bitmap layer opacity Signed-off-by: Ihor Dykhta --- src/layers/src/bitmap-layer/bitmap-layer.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/layers/src/bitmap-layer/bitmap-layer.ts b/src/layers/src/bitmap-layer/bitmap-layer.ts index 126f413ec4..dc5963e7d4 100644 --- a/src/layers/src/bitmap-layer/bitmap-layer.ts +++ b/src/layers/src/bitmap-layer/bitmap-layer.ts @@ -294,14 +294,7 @@ export default class BitmapOverlayLayer extends Layer { bounds: activeBounds, opacity: visConfig.opacity ?? 1, pickable: false, - visible, - parameters: { - blend: true, - blendColorSrcFactor: 'one', - blendColorDstFactor: 'one-minus-src-alpha', - blendAlphaSrcFactor: 'one', - blendAlphaDstFactor: 'one-minus-src-alpha' - } + visible }) ]; From 0037aabd236b875aeacdc6b22b58bd4b189fec88 Mon Sep 17 00:00:00 2001 From: Ihor Dykhta Date: Mon, 1 Jun 2026 00:05:17 +0300 Subject: [PATCH 2/3] fix: bitmap layer fixes Signed-off-by: Ihor Dykhta --- .../src/side-panel/layer-panel/layer-configurator.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/src/side-panel/layer-panel/layer-configurator.tsx b/src/components/src/side-panel/layer-panel/layer-configurator.tsx index 45f0526f6a..aa32bc653b 100644 --- a/src/components/src/side-panel/layer-panel/layer-configurator.tsx +++ b/src/components/src/side-panel/layer-panel/layer-configurator.tsx @@ -171,7 +171,7 @@ const BitmapImageSourceSection: React.FC = ({dataset, on (dataset.metadata as BitmapDatasetMetadata).imageUrl = imageUrl; (dataset.metadata as BitmapDatasetMetadata).isDataUri = isDataUri; } - // Force a layer re-render by bumping a timestamp in visConfig + // Force a layer re-render by bumping a no-op visConfig change onChange({_imageTs: Date.now()}); }, [dataset, onChange] From e596bed82ef38ea0cbaaf32d979fd69b7e377fc6 Mon Sep 17 00:00:00 2001 From: Ihor Dykhta Date: Mon, 1 Jun 2026 01:15:34 +0300 Subject: [PATCH 3/3] add 3-point alignment Signed-off-by: Ihor Dykhta --- src/components/src/map-container.tsx | 12 ++ .../layer-panel/layer-configurator.tsx | 75 ++++++++ src/layers/src/bitmap-layer/bitmap-layer.ts | 181 +++++++++++++++++- src/localization/src/translations/en.ts | 2 + 4 files changed, 266 insertions(+), 4 deletions(-) diff --git a/src/components/src/map-container.tsx b/src/components/src/map-container.tsx index ce66a73290..fd2cb792e2 100644 --- a/src/components/src/map-container.tsx +++ b/src/components/src/map-container.tsx @@ -1145,6 +1145,18 @@ export default function MapContainerFactory( onClick={(data, event) => { // @ts-ignore normalizeEvent(event.srcEvent, viewport); + + // Handle bitmap layer alignment mode: if a bitmap layer is waiting + // for a map click, route the click coordinate to it + const aligningLayer = visState.layers.find( + (l: any) => l.type === 'bitmap' && l.alignWaitingForMap && l.config.isVisible + ); + if (aligningLayer && data.coordinate) { + (aligningLayer as any).onAlignMapClick(data.coordinate as [number, number]); + this._onRedrawNeeded(0); + return; + } + const res = EditorLayerUtils.onClick(data, event, { editorMenuActive, editor, diff --git a/src/components/src/side-panel/layer-panel/layer-configurator.tsx b/src/components/src/side-panel/layer-panel/layer-configurator.tsx index aa32bc653b..93d1dc4e47 100644 --- a/src/components/src/side-panel/layer-panel/layer-configurator.tsx +++ b/src/components/src/side-panel/layer-panel/layer-configurator.tsx @@ -292,6 +292,75 @@ const BitmapImageSourceSection: React.FC = ({dataset, on ); }; +const AlignModeContainer = styled.div` + padding: 8px 0; + font-size: 11px; + color: ${props => props.theme.textColor}; +`; + +const AlignModeStatus = styled.div<{$highlight?: boolean}>` + padding: 6px 8px; + border-radius: 4px; + background: ${props => props.$highlight ? props.theme.panelBackgroundHover : 'transparent'}; + margin-bottom: 6px; + line-height: 1.4; +`; + +const AlignModeResetButton = styled.button` + background: ${props => props.theme.secondaryBtnBgd}; + color: ${props => props.theme.secondaryBtnColor}; + border: 1px solid ${props => props.theme.secondaryBtnBgd}; + border-radius: 4px; + padding: 4px 10px; + font-size: 11px; + cursor: pointer; + margin-top: 4px; + &:hover { + background: ${props => props.theme.secondaryBtnBgdHover}; + } +`; + +const AlignPointCount = styled.span` + font-weight: 600; + color: ${props => props.theme.activeColor}; +`; + +type BitmapAlignModeUIProps = { + layer: any; + onChange: (v: Record) => void; +}; + +const BitmapAlignModeUI: React.FC = ({layer, onChange}) => { + const pointCount = layer.alignControlPoints?.length || 0; + const waitingForMap = layer.alignWaitingForMap; + + const onReset = useCallback(() => { + layer.resetAlignControlPoints(); + onChange({_alignTs: Date.now()}); + }, [layer, onChange]); + + return ( + + {waitingForMap ? ( + + Now click the same point on the map (where it should be). + + ) : ( + + Click a recognizable point on the image. + + )} +
+ {pointCount} point pair{pointCount !== 1 ? 's' : ''} set + {pointCount >= 2 && ' — bounds auto-updated'} +
+ {pointCount > 0 && ( + Reset Points + )} +
+ ); +}; + export const getLayerFields = (datasets: Datasets, layer: Layer) => datasets[layer.config?.dataId || ''] ? datasets[layer.config.dataId].fields : []; @@ -1452,6 +1521,12 @@ export default function LayerConfiguratorFactory( + + + {layer.config.visConfig.alignMode && ( + + )} + ); } diff --git a/src/layers/src/bitmap-layer/bitmap-layer.ts b/src/layers/src/bitmap-layer/bitmap-layer.ts index dc5963e7d4..60a9801da9 100644 --- a/src/layers/src/bitmap-layer/bitmap-layer.ts +++ b/src/layers/src/bitmap-layer/bitmap-layer.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright contributors to the kepler.gl project -import {BitmapLayer as DeckBitmapLayer, PathLayer} from '@deck.gl/layers'; +import {BitmapLayer as DeckBitmapLayer, PathLayer, ScatterplotLayer} from '@deck.gl/layers'; import { EditableGeoJsonLayer, ModifyMode, @@ -32,6 +32,7 @@ export type BitmapLayerVisConfigSettings = { opacity: VisConfigNumber; showBounds: VisConfigBoolean; editBounds: VisConfigBoolean; + alignMode: VisConfigBoolean; boundsWest: VisConfigNumber; boundsSouth: VisConfigNumber; boundsEast: VisConfigNumber; @@ -58,6 +59,13 @@ export const bitmapVisConfigs = { group: 'display', property: 'editBounds' } as VisConfigBoolean, + alignMode: { + type: 'boolean', + defaultValue: false, + label: 'layerVisConfigs.alignMode', + group: 'display', + property: 'alignMode' + } as VisConfigBoolean, boundsWest: { type: 'number', defaultValue: 0, @@ -100,6 +108,11 @@ export const bitmapVisConfigs = { } as VisConfigNumber }; +export type AlignControlPoint = { + uv: [number, number]; // normalized image coordinates (0-1) + geo: [number, number]; // [lng, lat] +}; + export default class BitmapOverlayLayer extends Layer { declare visConfigSettings: BitmapLayerVisConfigSettings; private _editFeatureCollection: any = null; @@ -107,6 +120,11 @@ export default class BitmapOverlayLayer extends Layer { private _onRedrawNeeded: (() => void) | undefined; private _rafId: number | undefined; + // Alignment mode state + alignControlPoints: AlignControlPoint[] = []; + alignWaitingForMap: boolean = false; + private _pendingUV: [number, number] | null = null; + constructor(props: {dataId: string; visConfig?: Record} & Record) { super(props); this.registerVisConfig(bitmapVisConfigs); @@ -117,6 +135,94 @@ export default class BitmapOverlayLayer extends Layer { this.meta = {}; } + // --- Alignment mode methods --- + + onAlignImageClick(uv: [number, number]): void { + this._pendingUV = uv; + this.alignWaitingForMap = true; + } + + onAlignMapClick(lngLat: [number, number]): void { + if (!this._pendingUV) return; + this.alignControlPoints = [ + ...this.alignControlPoints, + {uv: this._pendingUV, geo: lngLat} + ]; + this._pendingUV = null; + this.alignWaitingForMap = false; + + if (this.alignControlPoints.length >= 2) { + this._computeBoundsFromControlPoints(); + } + } + + resetAlignControlPoints(): void { + this.alignControlPoints = []; + this.alignWaitingForMap = false; + this._pendingUV = null; + } + + private _computeBoundsFromControlPoints(): void { + const pts = this.alignControlPoints; + if (pts.length < 2) return; + + // Solve affine: lng = a * u + b, lat = c * v + d + // From 2+ points, use least squares (for 2 points, exact solution) + const n = pts.length; + let sumU = 0, sumLng = 0, sumUU = 0, sumULng = 0; + let sumV = 0, sumLat = 0, sumVV = 0, sumVLat = 0; + for (const p of pts) { + const [u, v] = p.uv; + const [lng, lat] = p.geo; + sumU += u; sumLng += lng; sumUU += u * u; sumULng += u * lng; + sumV += v; sumLat += lat; sumVV += v * v; sumVLat += v * lat; + } + + // lng = a*u + b (solve for a, b) + const detU = n * sumUU - sumU * sumU; + if (Math.abs(detU) < 1e-12) return; + const a = (n * sumULng - sumU * sumLng) / detU; + const b = (sumLng - a * sumU) / n; + + // lat = c*v + d (solve for c, d) + const detV = n * sumVV - sumV * sumV; + if (Math.abs(detV) < 1e-12) return; + const c = (n * sumVLat - sumV * sumLat) / detV; + const d = (sumLat - c * sumV) / n; + + // Bounds: image corners are (0,0), (1,0), (1,1), (0,1) + // UV origin (0,0) = top-left of image, (1,1) = bottom-right + const west = b; // u=0 + const east = a + b; // u=1 + const north = d; // v=0 (top of image) + const south = c + d; // v=1 (bottom of image) + + this.updateLayerVisConfig({ + boundsWest: Math.min(west, east), + boundsEast: Math.max(west, east), + boundsSouth: Math.min(south, north), + boundsNorth: Math.max(south, north) + }); + + this._onRedrawNeeded?.(); + } + + updateLayerVisConfig(newVisConfig: Record): this { + if ('alignMode' in newVisConfig && !newVisConfig.alignMode) { + this.resetAlignControlPoints(); + } + // Make alignMode and editBounds mutually exclusive + if (newVisConfig.alignMode && this.config.visConfig.editBounds) { + newVisConfig.editBounds = false; + } + if (newVisConfig.editBounds && this.config.visConfig.alignMode) { + newVisConfig.alignMode = false; + this.resetAlignControlPoints(); + } + super.updateLayerVisConfig(newVisConfig); + return this; + } + static findDefaultLayerProps(dataset: KeplerDataset): FindDefaultLayerPropsReturnValue { if (dataset.type !== DatasetType.BITMAP) { return {props: []}; @@ -237,7 +343,12 @@ export default class BitmapOverlayLayer extends Layer { const {visible} = this.getDefaultDeckLayerProps(opts); - const [west, south, east, north] = bounds as [number, number, number, number]; + // Use live visConfig bounds (handles direct mutations from alignment mode) + // rather than the potentially stale cached data.bounds from formatLayerData + const west = visConfig.boundsWest ?? bounds[0]; + const south = visConfig.boundsSouth ?? bounds[1]; + const east = visConfig.boundsEast ?? bounds[2]; + const north = visConfig.boundsNorth ?? bounds[3]; const dataBoundsKey = `${west},${south},${east},${north}`; // Rebuild the editable feature collection only when data.bounds changes @@ -287,17 +398,79 @@ export default class BitmapOverlayLayer extends Layer { activeBounds = [west, south, east, north]; } + const isAligning = visConfig.alignMode; + const layers: any[] = [ new DeckBitmapLayer({ id: this.id, image: imageUrl, bounds: activeBounds, opacity: visConfig.opacity ?? 1, - pickable: false, - visible + pickable: isAligning, + visible, + onClick: isAligning + ? (info: any) => { + if (this.alignWaitingForMap) { + // Second click: use the geo coordinate under the cursor + if (info.coordinate) { + this.onAlignMapClick(info.coordinate as [number, number]); + this._onRedrawNeeded?.(); + } + } else { + // First click: capture the UV position on the image + if (info.bitmap?.uv) { + this.onAlignImageClick(info.bitmap.uv as [number, number]); + this._onRedrawNeeded?.(); + } + } + return true; + } + : undefined }) ]; + // Visualize alignment control points + if (visConfig.alignMode && this.alignControlPoints.length > 0) { + layers.push( + new ScatterplotLayer({ + id: `${this.id}-align-pts`, + data: this.alignControlPoints, + getPosition: (d: AlignControlPoint) => d.geo, + getFillColor: [255, 100, 100, 220], + getRadius: 6, + radiusUnits: 'pixels', + pickable: false, + visible, + updateTriggers: { + getPosition: this.alignControlPoints.length + } + }) + ); + } + + // Visualize pending point (waiting for map click) + if (visConfig.alignMode && this._pendingUV) { + const [u, v] = this._pendingUV; + const [aW, aS, aE, aN] = activeBounds; + const pendingLng = aW + u * (aE - aW); + const pendingLat = aN + v * (aS - aN); + layers.push( + new ScatterplotLayer({ + id: `${this.id}-align-pending`, + data: [{position: [pendingLng, pendingLat]}], + getPosition: (d: any) => d.position, + getFillColor: [100, 200, 255, 220], + getLineColor: [255, 255, 255, 255], + getRadius: 8, + radiusUnits: 'pixels', + stroked: true, + lineWidthMinPixels: 2, + pickable: false, + visible + }) + ); + } + if (visConfig.showBounds && !visConfig.editBounds) { // Static boundary outline (no interaction) const [aW, aS, aE, aN] = activeBounds; diff --git a/src/localization/src/translations/en.ts b/src/localization/src/translations/en.ts index c70e03c659..0a6f39f0d8 100644 --- a/src/localization/src/translations/en.ts +++ b/src/localization/src/translations/en.ts @@ -105,6 +105,7 @@ export default { appearance: 'Appearance', bounds: 'Bounds', imageSource: 'Image Source', + alignment: 'Alignment', uniqueIdField: 'Unique ID Field', type: { point: 'point', @@ -155,6 +156,7 @@ export default { imageUrl: 'Image URL', showBounds: 'Show Bounds', editBounds: 'Drag corners to resize', + alignMode: 'Align to map', boundsWest: 'West', boundsSouth: 'South', boundsEast: 'East',