Skip to content

Commit ae9b476

Browse files
authored
Merge branch 'master' into claude/fix-deckgl-9899-xrY08
2 parents c26bef0 + 224e1b6 commit ae9b476

File tree

12 files changed

+336
-78
lines changed

12 files changed

+336
-78
lines changed

bindings/pydeck/pydeck/bindings/json_tools.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,18 @@ def default_serialize(o, remap_function=lower_camel_case_keys):
7272
"""Default method for rendering JSON from a dictionary"""
7373
if issubclass(type(o), PydeckType):
7474
return repr(o)
75-
attrs = vars(o)
75+
76+
# Handle objects without __dict__ (e.g., pandas 3.x DataFrames if detection fails)
77+
try:
78+
attrs = vars(o)
79+
except TypeError:
80+
if hasattr(o, "to_dict") and callable(getattr(o, "to_dict", None)):
81+
try:
82+
return o.to_dict(orient="records")
83+
except (TypeError, ValueError):
84+
pass
85+
return str(o)
86+
7687
attrs = {k: v for k, v in attrs.items() if v is not None}
7788
for ignore_attr in IGNORE_KEYS:
7889
if ignore_attr in attrs:

bindings/pydeck/pydeck/data_utils/type_checking.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,19 @@ def is_pandas_df(obj):
66
bool
77
Returns True if object is a Pandas DataFrame and False otherwise
88
"""
9-
return obj.__class__.__module__ == "pandas.core.frame" and obj.to_records and obj.to_dict
9+
# Use duck-typing approach that works with both pandas 2.x and 3.x
10+
# Check for DataFrame-specific methods and the class name
11+
try:
12+
return (
13+
obj.__class__.__name__ == "DataFrame"
14+
and hasattr(obj, "to_records")
15+
and hasattr(obj, "to_dict")
16+
and hasattr(obj, "columns")
17+
and callable(getattr(obj, "to_records", None))
18+
and callable(getattr(obj, "to_dict", None))
19+
)
20+
except (AttributeError, TypeError):
21+
return False
1022

1123

1224
def has_geo_interface(obj):

bindings/pydeck/tests/test_data_utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,35 @@ def test_is_pandas_df():
4949
assert is_pandas_df(pd.DataFrame())
5050

5151

52+
def test_is_pandas_df_negative_cases():
53+
"""Test that is_pandas_df correctly rejects non-DataFrame objects"""
54+
assert not is_pandas_df(None)
55+
assert not is_pandas_df([1, 2, 3])
56+
assert not is_pandas_df({"a": 1})
57+
assert not is_pandas_df("string")
58+
assert not is_pandas_df(42)
59+
60+
# Test object with some but not all DataFrame methods
61+
class FakeDataFrame:
62+
def to_records(self):
63+
pass
64+
65+
assert not is_pandas_df(FakeDataFrame())
66+
67+
68+
def test_is_pandas_df_duck_typing():
69+
"""Test that is_pandas_df works with DataFrame duck-typing"""
70+
df = pd.DataFrame({"a": [1, 2, 3]})
71+
assert is_pandas_df(df)
72+
73+
# Verify the methods we rely on exist and are callable
74+
assert hasattr(df, "to_records")
75+
assert hasattr(df, "to_dict")
76+
assert hasattr(df, "columns")
77+
assert callable(df.to_records)
78+
assert callable(df.to_dict)
79+
80+
5281
def test_compute_view():
5382
actual = compute_view(POINTS, 0.95, ViewState)
5483
actual_pandas = compute_view(pd.DataFrame(POINTS), 0.95, ViewState)

docs/api-reference/core/view.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,24 @@ A unique id of the view. In a multi-view use case, this is important for matchin
2828

2929
#### `x` (string | number, optional) {#x}
3030

31-
A relative (e.g. `'50%'`) or absolute position. Default `0`.
31+
A relative (e.g. `'50%'`) or absolute position. Accepts CSS-like expressions that mix numbers, `%`, `px`, whitespace/parentheses, and `calc()` with `+`/`-` to combine units. Default `0`.
3232

3333

3434
#### `y` (string | number, optional) {#y}
3535

36-
A relative (e.g. `'50%'`) or absolute position. Default `0`.
36+
A relative (e.g. `'50%'`) or absolute position. Accepts CSS-like expressions that mix numbers, `%`, `px`, whitespace/parentheses, and `calc()` with `+`/`-` to combine units. Default `0`.
3737

3838
#### `width` (string | number, optional) {#width}
3939

40-
A relative (e.g. `'50%'`) or absolute extent. Default `'100%'`.
40+
A relative (e.g. `'50%'`) or absolute extent. Accepts CSS-like expressions that mix numbers, `%`, `px`, whitespace/parentheses, and `calc()` with `+`/`-` to combine units. Default `'100%'`.
4141

4242
#### `height` (string | number, optional) {#height}
4343

44-
A relative (e.g. `'50%'`) or absolute extent. Default `'100%'`.
44+
A relative (e.g. `'50%'`) or absolute extent. Accepts CSS-like expressions that mix numbers, `%`, `px`, whitespace/parentheses, and `calc()` with `+`/`-` to combine units. Default `'100%'`.
4545

4646
#### `padding` (object, optional) {#padding}
4747

48-
Padding around the viewport, in the shape of `{left, right, top, bottom}` where each value is either a relative (e.g. `'50%'`) or absolute pixels. This can be used to move the "look at"/target/vanishing point away from the center of the viewport rectangle.
48+
Padding around the viewport, in the shape of `{left, right, top, bottom}` where each value is either a relative (e.g. `'50%'`) or absolute pixels. These values support the same CSS-style expressions (numbers/percentages/`px` with parentheses and `calc()` addition/subtraction) as `x`, `y`, `width`, and `height`. This can be used to move the "look at"/target/vanishing point away from the center of the viewport rectangle.
4949

5050
#### `controller` (Function | boolean | object, optional) {#controller}
5151

docs/developer-guide/views.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ A [View](../api-reference/core/view.md) instance defines the following informati
3333

3434
* A unique `id`.
3535
* The position and extent of the view on the canvas: `x`, `y`, `width`, and `height`.
36+
These properties (and padding) accept CSS-style expressions that combine numbers, percentages, `px` units, parentheses, and `calc()` addition/subtraction so you can mix relative and absolute measurements like `calc(50% - 10px)`.
3637
* Certain camera parameters specifying how your data should be projected into this view, e.g. field of view, near/far planes, perspective vs. orthographic, etc.
3738
* The [controller](../api-reference/core/controller.md) to be used for this view. A controller listens to pointer events and touch gestures, and translates user input into changes in the view state. If enabled, the camera becomes interactive.
3839

docs/whats-new.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
This page contains highlights of each deck.gl release. Also check our [vis.gl blog](https://medium.com/vis-gl) for news about new releases and features in deck.gl.
44

5+
## deck.gl v9.3 (in development)
6+
7+
### Core
8+
9+
- View layout props (`x`, `y`, `width`, `height`, and padding) now accept CSS-style expressions such as `calc(50% - 10px)` so you can mix relative percentages with fixed pixel offsets when arranging multi-view layouts.
10+
511
## deck.gl v9.2
612

713
Target release date: September, 2025

modules/aggregation-layers/src/heatmap-layer/heatmap-layer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ export default class HeatmapLayer<
413413
targetTexture: weightsTexture!,
414414
parameters: {
415415
depthWriteEnabled: false,
416+
blend: true,
416417
blendColorOperation: 'add',
417418
blendColorSrcFactor: 'one',
418419
blendColorDstFactor: 'one',
@@ -451,6 +452,7 @@ export default class HeatmapLayer<
451452
topology: 'point-list',
452453
parameters: {
453454
depthWriteEnabled: false,
455+
blend: true,
454456
blendColorOperation: 'max',
455457
blendAlphaOperation: 'max',
456458
blendColorSrcFactor: 'one',

modules/core/src/lib/deck.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,7 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
508508
// Update the animation loop
509509
this.animationLoop?.setProps(resolvedProps);
510510

511-
if (props.useDevicePixels !== undefined && this.device?.canvasContext) {
511+
if (props.useDevicePixels !== undefined && this.device?.canvasContext?.setProps) {
512512
this.device.canvasContext.setProps({useDevicePixels: props.useDevicePixels});
513513
}
514514

modules/core/src/utils/positions.ts

Lines changed: 199 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,216 @@
22
// SPDX-License-Identifier: MIT
33
// Copyright (c) vis.gl contributors
44

5-
const PERCENT_OR_PIXELS_REGEX = /([0-9]+\.?[0-9]*)(%|px)/;
5+
export type LayoutExpression =
6+
| {type: 'literal'; value: number}
7+
| {type: 'percentage'; value: number}
8+
| {type: 'binary'; operator: '+' | '-'; left: LayoutExpression; right: LayoutExpression};
69

7-
export type Position = {
8-
position: number;
9-
relative: boolean;
10-
};
10+
type Token =
11+
| {type: 'number'; value: number}
12+
| {type: 'word'; value: string}
13+
| {type: 'symbol'; value: string};
1114

12-
// Takes a number or a string of formats `50%`, `33.3%` or `200px`
13-
export function parsePosition(value: number | string): Position {
15+
const NUMBER_REGEX = /^(?:\d+\.?\d*|\.\d+)$/;
16+
17+
// Takes a number or a string expression that may include numbers, percentages, `px` units or
18+
// CSS-style `calc()` expressions containing `+`/`-` operations and parentheses.
19+
export function parsePosition(value: number | string): LayoutExpression {
1420
switch (typeof value) {
1521
case 'number':
16-
return {
17-
position: value,
18-
relative: false
19-
};
22+
if (!Number.isFinite(value)) {
23+
throw new Error(`Could not parse position string ${value}`);
24+
}
25+
return {type: 'literal', value};
2026

2127
case 'string':
22-
const match = PERCENT_OR_PIXELS_REGEX.exec(value);
23-
if (match && match.length >= 3) {
24-
const relative = match[2] === '%';
25-
const position = parseFloat(match[1]);
26-
return {
27-
position: relative ? position / 100 : position,
28-
relative
29-
};
28+
try {
29+
const tokens = tokenize(value);
30+
const parser = new LayoutExpressionParser(tokens);
31+
return parser.parseExpression();
32+
} catch (error) {
33+
const reason = error instanceof Error ? error.message : String(error);
34+
throw new Error(`Could not parse position string ${value}: ${reason}`);
3035
}
31-
// fallthrough
3236

3337
default:
34-
// eslint-disable-line
3538
throw new Error(`Could not parse position string ${value}`);
3639
}
3740
}
3841

39-
export function getPosition(position: Position, extent: number): number {
40-
return position.relative ? Math.round(position.position * extent) : position.position;
42+
export function evaluateLayoutExpression(expression: LayoutExpression, extent: number): number {
43+
switch (expression.type) {
44+
case 'literal':
45+
return expression.value;
46+
case 'percentage':
47+
return Math.round(expression.value * extent);
48+
case 'binary':
49+
const left = evaluateLayoutExpression(expression.left, extent);
50+
const right = evaluateLayoutExpression(expression.right, extent);
51+
return expression.operator === '+' ? left + right : left - right;
52+
default:
53+
throw new Error('Unknown layout expression type');
54+
}
55+
}
56+
57+
export function getPosition(expression: LayoutExpression, extent: number): number {
58+
return evaluateLayoutExpression(expression, extent);
59+
}
60+
61+
function tokenize(input: string): Token[] {
62+
const tokens: Token[] = [];
63+
let index = 0;
64+
while (index < input.length) {
65+
const char = input[index];
66+
if (/\s/.test(char)) {
67+
index++;
68+
continue;
69+
}
70+
if (char === '+' || char === '-' || char === '(' || char === ')' || char === '%') {
71+
tokens.push({type: 'symbol', value: char});
72+
index++;
73+
continue;
74+
}
75+
if (isDigit(char) || char === '.') {
76+
const start = index;
77+
let hasDecimal = char === '.';
78+
index++;
79+
while (index < input.length) {
80+
const next = input[index];
81+
if (isDigit(next)) {
82+
index++;
83+
continue;
84+
}
85+
if (next === '.' && !hasDecimal) {
86+
hasDecimal = true;
87+
index++;
88+
continue;
89+
}
90+
break;
91+
}
92+
const numberString = input.slice(start, index);
93+
if (!NUMBER_REGEX.test(numberString)) {
94+
throw new Error('Invalid number token');
95+
}
96+
tokens.push({type: 'number', value: parseFloat(numberString)});
97+
continue;
98+
}
99+
if (isAlpha(char)) {
100+
const start = index;
101+
while (index < input.length && isAlpha(input[index])) {
102+
index++;
103+
}
104+
const word = input.slice(start, index).toLowerCase();
105+
tokens.push({type: 'word', value: word});
106+
continue;
107+
}
108+
throw new Error('Invalid token in position string');
109+
}
110+
return tokens;
111+
}
112+
113+
class LayoutExpressionParser {
114+
private tokens: Token[];
115+
private index = 0;
116+
117+
constructor(tokens: Token[]) {
118+
this.tokens = tokens;
119+
}
120+
121+
parseExpression(): LayoutExpression {
122+
const expression = this.parseBinaryExpression();
123+
if (this.index < this.tokens.length) {
124+
throw new Error('Unexpected token at end of expression');
125+
}
126+
return expression;
127+
}
128+
129+
private parseBinaryExpression(): LayoutExpression {
130+
let expression = this.parseFactor();
131+
let token = this.peek();
132+
while (isAddSubSymbol(token)) {
133+
this.index++;
134+
const right = this.parseFactor();
135+
expression = {type: 'binary', operator: token.value, left: expression, right};
136+
token = this.peek();
137+
}
138+
return expression;
139+
}
140+
141+
private parseFactor(): LayoutExpression {
142+
const token = this.peek();
143+
if (!token) {
144+
throw new Error('Unexpected end of expression');
145+
}
146+
147+
if (token.type === 'symbol' && token.value === '+') {
148+
this.index++;
149+
return this.parseFactor();
150+
}
151+
if (token.type === 'symbol' && token.value === '-') {
152+
this.index++;
153+
const factor = this.parseFactor();
154+
return {type: 'binary', operator: '-', left: {type: 'literal', value: 0}, right: factor};
155+
}
156+
if (token.type === 'symbol' && token.value === '(') {
157+
this.index++;
158+
const expression = this.parseBinaryExpression();
159+
if (!this.consumeSymbol(')')) {
160+
throw new Error('Missing closing parenthesis');
161+
}
162+
return expression;
163+
}
164+
if (token.type === 'word' && token.value === 'calc') {
165+
this.index++;
166+
if (!this.consumeSymbol('(')) {
167+
throw new Error('Missing opening parenthesis after calc');
168+
}
169+
const expression = this.parseBinaryExpression();
170+
if (!this.consumeSymbol(')')) {
171+
throw new Error('Missing closing parenthesis');
172+
}
173+
return expression;
174+
}
175+
if (token.type === 'number') {
176+
this.index++;
177+
const numberValue = token.value;
178+
const nextToken = this.peek();
179+
if (nextToken && nextToken.type === 'symbol' && nextToken.value === '%') {
180+
this.index++;
181+
return {type: 'percentage', value: numberValue / 100};
182+
}
183+
if (nextToken && nextToken.type === 'word' && nextToken.value === 'px') {
184+
this.index++;
185+
return {type: 'literal', value: numberValue};
186+
}
187+
return {type: 'literal', value: numberValue};
188+
}
189+
190+
throw new Error('Unexpected token in expression');
191+
}
192+
193+
private consumeSymbol(value: string): boolean {
194+
const token = this.peek();
195+
if (token && token.type === 'symbol' && token.value === value) {
196+
this.index++;
197+
return true;
198+
}
199+
return false;
200+
}
201+
202+
private peek(): Token | null {
203+
return this.tokens[this.index] || null;
204+
}
205+
}
206+
207+
function isDigit(char: string): boolean {
208+
return char >= '0' && char <= '9';
209+
}
210+
211+
function isAlpha(char: string): boolean {
212+
return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z');
213+
}
214+
215+
function isAddSubSymbol(token: Token | null): token is Token & {type: 'symbol'; value: '+' | '-'} {
216+
return Boolean(token && token.type === 'symbol' && (token.value === '+' || token.value === '-'));
41217
}

0 commit comments

Comments
 (0)