Skip to content
Open
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
12 changes: 12 additions & 0 deletions src/components/src/map-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
77 changes: 76 additions & 1 deletion src/components/src/side-panel/layer-panel/layer-configurator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ const BitmapImageSourceSection: React.FC<BitmapImageSourceProps> = ({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]
Expand Down Expand Up @@ -292,6 +292,75 @@ const BitmapImageSourceSection: React.FC<BitmapImageSourceProps> = ({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<string, any>) => void;
};

const BitmapAlignModeUI: React.FC<BitmapAlignModeUIProps> = ({layer, onChange}) => {
const pointCount = layer.alignControlPoints?.length || 0;
const waitingForMap = layer.alignWaitingForMap;

const onReset = useCallback(() => {
layer.resetAlignControlPoints();
onChange({_alignTs: Date.now()});
}, [layer, onChange]);

return (
<AlignModeContainer>
{waitingForMap ? (
<AlignModeStatus $highlight>
Now click the <strong>same point on the map</strong> (where it should be).
</AlignModeStatus>
) : (
<AlignModeStatus $highlight>
Click a <strong>recognizable point on the image</strong>.
</AlignModeStatus>
)}
<div style={{marginTop: '4px'}}>
<AlignPointCount>{pointCount}</AlignPointCount> point pair{pointCount !== 1 ? 's' : ''} set
{pointCount >= 2 && ' — bounds auto-updated'}
</div>
{pointCount > 0 && (
<AlignModeResetButton onClick={onReset}>Reset Points</AlignModeResetButton>
)}
</AlignModeContainer>
);
};

export const getLayerFields = (datasets: Datasets, layer: Layer) =>
datasets[layer.config?.dataId || ''] ? datasets[layer.config.dataId].fields : [];

Expand Down Expand Up @@ -1452,6 +1521,12 @@ export default function LayerConfiguratorFactory(
</div>
<VisConfigSwitch {...layer.visConfigSettings.showBounds} {...visConfiguratorProps} />
</LayerConfigGroup>
<LayerConfigGroup label={'layer.alignment'} collapsible>
<VisConfigSwitch {...layer.visConfigSettings.alignMode} {...visConfiguratorProps} />
{layer.config.visConfig.alignMode && (
<BitmapAlignModeUI layer={layer} onChange={visConfiguratorProps.onChange} />
)}
</LayerConfigGroup>
</StyledLayerVisualConfigurator>
);
}
Expand Down
186 changes: 176 additions & 10 deletions src/layers/src/bitmap-layer/bitmap-layer.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -32,6 +32,7 @@ export type BitmapLayerVisConfigSettings = {
opacity: VisConfigNumber;
showBounds: VisConfigBoolean;
editBounds: VisConfigBoolean;
alignMode: VisConfigBoolean;
boundsWest: VisConfigNumber;
boundsSouth: VisConfigNumber;
boundsEast: VisConfigNumber;
Expand All @@ -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,
Expand Down Expand Up @@ -100,13 +108,23 @@ 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;
private _prevDataBoundsKey: string = '';
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<string, any>} & Record<string, any>) {
super(props);
this.registerVisConfig(bitmapVisConfigs);
Expand All @@ -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<string, any>): 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: []};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -287,24 +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,
pickable: isAligning,
visible,
parameters: {
blend: true,
blendColorSrcFactor: 'one',
blendColorDstFactor: 'one-minus-src-alpha',
blendAlphaSrcFactor: 'one',
blendAlphaDstFactor: 'one-minus-src-alpha'
}
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;
Expand Down
2 changes: 2 additions & 0 deletions src/localization/src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export default {
appearance: 'Appearance',
bounds: 'Bounds',
imageSource: 'Image Source',
alignment: 'Alignment',
uniqueIdField: 'Unique ID Field',
type: {
point: 'point',
Expand Down Expand Up @@ -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',
Expand Down
Loading