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
10 changes: 5 additions & 5 deletions docs/api-reference/core/view.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,24 @@ A unique id of the view. In a multi-view use case, this is important for matchin

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

A relative (e.g. `'50%'`) or absolute position. Default `0`.
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`.


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

A relative (e.g. `'50%'`) or absolute position. Default `0`.
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`.

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

A relative (e.g. `'50%'`) or absolute extent. Default `'100%'`.
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%'`.

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

A relative (e.g. `'50%'`) or absolute extent. Default `'100%'`.
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%'`.

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

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.
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.

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

Expand Down
1 change: 1 addition & 0 deletions docs/developer-guide/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ A [View](../api-reference/core/view.md) instance defines the following informati

* A unique `id`.
* The position and extent of the view on the canvas: `x`, `y`, `width`, and `height`.
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)`.
* 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.
* 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.

Expand Down
6 changes: 6 additions & 0 deletions docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

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.

## deck.gl v9.3 (in development)

### Core

- 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.

## deck.gl v9.2

Target release date: September, 2025
Expand Down
222 changes: 199 additions & 23 deletions modules/core/src/utils/positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,216 @@
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

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

export type Position = {
position: number;
relative: boolean;
};
type Token =
| {type: 'number'; value: number}
| {type: 'word'; value: string}
| {type: 'symbol'; value: string};

// Takes a number or a string of formats `50%`, `33.3%` or `200px`
export function parsePosition(value: number | string): Position {
const NUMBER_REGEX = /^(?:\d+\.?\d*|\.\d+)$/;

// Takes a number or a string expression that may include numbers, percentages, `px` units or
// CSS-style `calc()` expressions containing `+`/`-` operations and parentheses.
export function parsePosition(value: number | string): LayoutExpression {
switch (typeof value) {
case 'number':
return {
position: value,
relative: false
};
if (!Number.isFinite(value)) {
throw new Error(`Could not parse position string ${value}`);
}
return {type: 'literal', value};

case 'string':
const match = PERCENT_OR_PIXELS_REGEX.exec(value);
if (match && match.length >= 3) {
const relative = match[2] === '%';
const position = parseFloat(match[1]);
return {
position: relative ? position / 100 : position,
relative
};
try {
const tokens = tokenize(value);
const parser = new LayoutExpressionParser(tokens);
return parser.parseExpression();
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`Could not parse position string ${value}: ${reason}`);
}
// fallthrough

default:
// eslint-disable-line
throw new Error(`Could not parse position string ${value}`);
}
}

export function getPosition(position: Position, extent: number): number {
return position.relative ? Math.round(position.position * extent) : position.position;
export function evaluateLayoutExpression(expression: LayoutExpression, extent: number): number {
switch (expression.type) {
case 'literal':
return expression.value;
case 'percentage':
return Math.round(expression.value * extent);
case 'binary':
const left = evaluateLayoutExpression(expression.left, extent);
const right = evaluateLayoutExpression(expression.right, extent);
return expression.operator === '+' ? left + right : left - right;
default:
throw new Error('Unknown layout expression type');
}
}

export function getPosition(expression: LayoutExpression, extent: number): number {
return evaluateLayoutExpression(expression, extent);
}

function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let index = 0;
while (index < input.length) {
const char = input[index];
if (/\s/.test(char)) {
index++;
continue;
}
if (char === '+' || char === '-' || char === '(' || char === ')' || char === '%') {
tokens.push({type: 'symbol', value: char});
index++;
continue;
}
if (isDigit(char) || char === '.') {
const start = index;
let hasDecimal = char === '.';
index++;
while (index < input.length) {
const next = input[index];
if (isDigit(next)) {
index++;
continue;
}
if (next === '.' && !hasDecimal) {
hasDecimal = true;
index++;
continue;
}
break;
}
const numberString = input.slice(start, index);
if (!NUMBER_REGEX.test(numberString)) {
throw new Error('Invalid number token');
}
tokens.push({type: 'number', value: parseFloat(numberString)});
continue;
}
if (isAlpha(char)) {
const start = index;
while (index < input.length && isAlpha(input[index])) {
index++;
}
const word = input.slice(start, index).toLowerCase();
tokens.push({type: 'word', value: word});
continue;
}
throw new Error('Invalid token in position string');
}
return tokens;
}

class LayoutExpressionParser {
private tokens: Token[];
private index = 0;

constructor(tokens: Token[]) {
this.tokens = tokens;
}

parseExpression(): LayoutExpression {
const expression = this.parseBinaryExpression();
if (this.index < this.tokens.length) {
throw new Error('Unexpected token at end of expression');
}
return expression;
}

private parseBinaryExpression(): LayoutExpression {
let expression = this.parseFactor();
let token = this.peek();
while (isAddSubSymbol(token)) {
this.index++;
const right = this.parseFactor();
expression = {type: 'binary', operator: token.value, left: expression, right};
token = this.peek();
}
return expression;
}

private parseFactor(): LayoutExpression {
const token = this.peek();
if (!token) {
throw new Error('Unexpected end of expression');
}

if (token.type === 'symbol' && token.value === '+') {
this.index++;
return this.parseFactor();
}
if (token.type === 'symbol' && token.value === '-') {
this.index++;
const factor = this.parseFactor();
return {type: 'binary', operator: '-', left: {type: 'literal', value: 0}, right: factor};
}
if (token.type === 'symbol' && token.value === '(') {
this.index++;
const expression = this.parseBinaryExpression();
if (!this.consumeSymbol(')')) {
throw new Error('Missing closing parenthesis');
}
return expression;
}
if (token.type === 'word' && token.value === 'calc') {
this.index++;
if (!this.consumeSymbol('(')) {
throw new Error('Missing opening parenthesis after calc');
}
const expression = this.parseBinaryExpression();
if (!this.consumeSymbol(')')) {
throw new Error('Missing closing parenthesis');
}
return expression;
}
if (token.type === 'number') {
this.index++;
const numberValue = token.value;
const nextToken = this.peek();
if (nextToken && nextToken.type === 'symbol' && nextToken.value === '%') {
this.index++;
return {type: 'percentage', value: numberValue / 100};
}
if (nextToken && nextToken.type === 'word' && nextToken.value === 'px') {
this.index++;
return {type: 'literal', value: numberValue};
}
return {type: 'literal', value: numberValue};
}

throw new Error('Unexpected token in expression');
}

private consumeSymbol(value: string): boolean {
const token = this.peek();
if (token && token.type === 'symbol' && token.value === value) {
this.index++;
return true;
}
return false;
}

private peek(): Token | null {
return this.tokens[this.index] || null;
}
}

function isDigit(char: string): boolean {
return char >= '0' && char <= '9';
}

function isAlpha(char: string): boolean {
return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z');
}

function isAddSubSymbol(token: Token | null): token is Token & {type: 'symbol'; value: '+' | '-'} {
return Boolean(token && token.type === 'symbol' && (token.value === '+' || token.value === '-'));
}
18 changes: 9 additions & 9 deletions modules/core/src/views/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Copyright (c) vis.gl contributors

import Viewport from '../viewports/viewport';
import {parsePosition, getPosition, Position} from '../utils/positions';
import {parsePosition, getPosition, LayoutExpression} from '../utils/positions';
import {deepEqual} from '../utils/deep-equal';
import type Controller from '../controllers/controller';
import type {ControllerOptions} from '../controllers/controller';
Expand Down Expand Up @@ -63,15 +63,15 @@ export default abstract class View<
abstract getViewportType(viewState: ViewState): ConstructorOf<Viewport>;
protected abstract get ControllerType(): ConstructorOf<Controller<any>>;

private _x: Position;
private _y: Position;
private _width: Position;
private _height: Position;
private _x: LayoutExpression;
private _y: LayoutExpression;
private _width: LayoutExpression;
private _height: LayoutExpression;
private _padding: {
left: Position;
right: Position;
top: Position;
bottom: Position;
left: LayoutExpression;
right: LayoutExpression;
top: LayoutExpression;
bottom: LayoutExpression;
} | null;

readonly props: ViewProps;
Expand Down
43 changes: 40 additions & 3 deletions test/apps/widgets-infovis/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {Deck, OrbitView, OrbitViewState} from '@deck.gl/core';
import {
Deck,
OrbitView,
OrbitViewState,
OrthographicView,
OrthographicViewState
} from '@deck.gl/core';
import {ScatterplotLayer} from '@deck.gl/layers';
import {
GimbalWidget,
Expand All @@ -24,27 +30,58 @@ function generateData(count) {
return result;
}

const INITIAL_VIEW_STATE = {
const INITIAL_ORBIT_VIEW_STATE = {
target: [0, 0, 0],
rotationX: 45,
rotationOrbit: 0,
zoom: 0
} as const satisfies OrbitViewState;

const INITIAL_ORTHO_VIEW_STATE = {
target: [0, 0, 0],
zoom: 0
} as const satisfies OrthographicViewState;

const INITIAL_VIEW_STATE = {
'orbit-view': INITIAL_ORBIT_VIEW_STATE,
'ortho-view': INITIAL_ORTHO_VIEW_STATE
};

const ORTHOGRAPHIC_POINTS = [
{position: [-40, -20, 0], color: [255, 99, 71]},
{position: [-10, 30, 0], color: [65, 105, 225]},
{position: [25, -5, 0], color: [60, 179, 113]},
{position: [40, 35, 0], color: [238, 130, 238]}
];

new Deck({
views: new OrbitView({id: 'default-view'}),
views: [
new OrbitView({id: 'orbit-view', x: 0, width: '50%'}),
new OrthographicView({id: 'ortho-view', x: '50%', width: '50%'})
],
initialViewState: INITIAL_VIEW_STATE,
controller: true,
layers: [
new ScatterplotLayer({
id: 'scatter',
viewId: 'orbit-view',
data: generateData(500),
getPosition: d => d.position,
getFillColor: d => d.color,
getRadius: 3,
pickable: true,
autoHighlight: true,
billboard: true
}),
new ScatterplotLayer({
id: 'ortho-scatter',
viewId: 'ortho-view',
data: ORTHOGRAPHIC_POINTS,
getPosition: d => d.position,
getFillColor: d => d.color,
getRadius: 8,
pickable: true,
autoHighlight: true
})
],
widgets: [
Expand Down
Loading
Loading