diff --git a/docs/api-reference/core/view.md b/docs/api-reference/core/view.md index 3898ae31a7d..37ba9950419 100644 --- a/docs/api-reference/core/view.md +++ b/docs/api-reference/core/view.md @@ -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} diff --git a/docs/developer-guide/views.md b/docs/developer-guide/views.md index 82cab7c0b87..ba68c27371d 100644 --- a/docs/developer-guide/views.md +++ b/docs/developer-guide/views.md @@ -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. diff --git a/docs/whats-new.md b/docs/whats-new.md index edb778e97bd..ac1c4f93e5a 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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 diff --git a/modules/core/src/utils/positions.ts b/modules/core/src/utils/positions.ts index ef5fca29d5e..4fb5f9a7e37 100644 --- a/modules/core/src/utils/positions.ts +++ b/modules/core/src/utils/positions.ts @@ -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 === '-')); } diff --git a/modules/core/src/views/view.ts b/modules/core/src/views/view.ts index 4bc358a841f..d0a80fd40a6 100644 --- a/modules/core/src/views/view.ts +++ b/modules/core/src/views/view.ts @@ -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'; @@ -63,15 +63,15 @@ export default abstract class View< abstract getViewportType(viewState: ViewState): ConstructorOf; protected abstract get ControllerType(): ConstructorOf>; - 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; diff --git a/test/apps/widgets-infovis/app.ts b/test/apps/widgets-infovis/app.ts index d383957404e..6ae66c8e24f 100644 --- a/test/apps/widgets-infovis/app.ts +++ b/test/apps/widgets-infovis/app.ts @@ -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, @@ -24,20 +30,41 @@ 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, @@ -45,6 +72,16 @@ new Deck({ 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: [ diff --git a/test/modules/core/utils/positions.spec.ts b/test/modules/core/utils/positions.spec.ts index b918c8f3967..d808019e8b3 100644 --- a/test/modules/core/utils/positions.spec.ts +++ b/test/modules/core/utils/positions.spec.ts @@ -3,66 +3,87 @@ // Copyright (c) vis.gl contributors import test from 'tape-promise/tape'; -import {parsePosition, getPosition} from '@deck.gl/core/utils/positions'; +import {parsePosition, getPosition, evaluateLayoutExpression} from '@deck.gl/core/utils/positions'; +import type {LayoutExpression} from '@deck.gl/core/utils/positions'; -const PARSE_TEST_CASES = [ +const EXPRESSION_TEST_CASES = [ + {title: 'number literal', value: 10, extent: 101, result: 10}, + {title: 'percent string', value: '10%', extent: 101, result: 10}, + {title: 'percent decimal string', value: '33.3%', extent: 100, result: 33}, + {title: 'pixel string', value: '10px', extent: 200, result: 10}, + {title: 'calc addition', value: 'calc(50% + 10px)', extent: 100, result: 60}, { - title: 'number', - value: 10, - result: {position: 10, relative: false} + title: 'calc subtraction with parentheses', + value: 'calc(100% - (25% + 10px))', + extent: 200, + result: 140 }, { - title: 'percent string', - value: '10%', - result: {position: 0.1, relative: true} + title: 'calc whitespace and nesting', + value: 'calc( (25% - 5px) + (10px - 5%) )', + extent: 120, + result: 29 }, - { - title: 'percent string', - value: '33.3%', - result: {position: 0.333, relative: true} - }, - { - title: 'pixel string', - value: '10px', - result: {position: 10, relative: false} - } + {title: 'calc unary minus', value: 'calc(-10px + 50%)', extent: 100, result: 40}, + {title: 'calc uppercase and px spacing', value: 'CALC(75% - 10 px)', extent: 80, result: 50}, + {title: 'calc percent only', value: 'calc(25%)', extent: 101, result: 25} ]; -const GET_TEST_CASES = [ +const EVALUATE_TEST_CASES: { + title: string; + expression: LayoutExpression; + extent: number; + result: number; +}[] = [ { - title: 'absolute', - position: {position: 10, relative: false}, - extent: 101, - result: 10 + title: 'binary addition tree', + expression: { + type: 'binary', + operator: '+', + left: {type: 'literal', value: 5}, + right: {type: 'percentage', value: 0.1} + }, + extent: 120, + result: 17 }, { - title: 'relative', - position: {position: 0.1, relative: true}, - extent: 101, - result: 10 + title: 'binary subtraction tree', + expression: { + type: 'binary', + operator: '-', + left: {type: 'percentage', value: 0.75}, + right: { + type: 'binary', + operator: '+', + left: {type: 'literal', value: 20}, + right: {type: 'percentage', value: 0.3} + } + }, + extent: 100, + result: 25 } ]; test('positions#import', t => { t.ok(parsePosition, 'parsePosition imported OK'); t.ok(getPosition, 'getPosition imported OK'); + t.ok(evaluateLayoutExpression, 'evaluateLayoutExpression imported OK'); t.end(); }); -test('parsePosition#tests', t => { - for (const tc of PARSE_TEST_CASES) { - const result = parsePosition(tc.value); - result.position = result.position.toPrecision(5); - tc.result.position = tc.result.position.toPrecision(5); - t.deepEqual(result, tc.result, `parsePosition ${tc.title} returned expected type`); +test('parsePosition#getPosition combinations', t => { + for (const tc of EXPRESSION_TEST_CASES) { + const expression = parsePosition(tc.value); + const result = getPosition(expression, tc.extent); + t.equal(result, tc.result, `parsePosition ${tc.title} returned expected result`); } t.end(); }); -test('getPosition#tests', t => { - for (const tc of GET_TEST_CASES) { - const result = getPosition(tc.position, tc.extent); - t.deepEqual(result, tc.result, `getPosition ${tc.title} returned expected type`); +test('evaluateLayoutExpression#trees', t => { + for (const tc of EVALUATE_TEST_CASES) { + const result = evaluateLayoutExpression(tc.expression, tc.extent); + t.equal(result, tc.result, `evaluateLayoutExpression ${tc.title} returned expected result`); } t.end(); });