Skip to content

Commit

Permalink
Add textFitWidth and textFitHeight to sprites (maplibre#4019)
Browse files Browse the repository at this point in the history
* Implement textFit solution

* Fix CHANGELOG

* Fix formatting

* Fix code hygiene

* Addressing PR comments

* More PR fixes

* Move testFit tests

* Lock stretchOnly sizes to whole numbers

* Updated expected

* Fix for expected

* I didn't realize that the right/bottom on content and stretchX/Y are the pixel past the actual last pixel.

* Move unit tests

* Add vertical text test

* Added textFit-grid-short-vertical test

* Exercise the other if statement in applyTextFit for coverage

* Update size

* Address missing code coverage lines
  • Loading branch information
DavidBuerer authored Apr 29, 2024
1 parent 054ce0e commit e0f688a
Show file tree
Hide file tree
Showing 32 changed files with 1,800 additions and 37 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
- Update Popup methods `addClass` & `removeClass` to return instance of Popup ([#3975](https://github.com/maplibre/maplibre-gl-js/pull/3975))

### ✨ Features and improvements
- _...Add new stuff here..._
- Sprites include optional textFitHeight and textFitWidth values ([#4019](https://github.com/maplibre/maplibre-gl-js/pull/4019))

### 🐞 Bug fixes
- _...Add new stuff here..._
Expand Down
9 changes: 8 additions & 1 deletion src/render/image_atlas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {register} from '../util/web_worker_transfer';
import potpack from 'potpack';

import type {StyleImage} from '../style/style_image';
import {TextFit} from '../style/style_image';
import type {ImageManager} from './image_manager';
import type {Texture} from './texture';
import type {Rect} from './glyph_atlas';
Expand All @@ -19,20 +20,26 @@ export class ImagePosition {
stretchY: Array<[number, number]>;
stretchX: Array<[number, number]>;
content: [number, number, number, number];
textFitWidth: TextFit;
textFitHeight: TextFit;

constructor(paddedRect: Rect, {
pixelRatio,
version,
stretchX,
stretchY,
content
content,
textFitWidth,
textFitHeight
}: StyleImage) {
this.paddedRect = paddedRect;
this.pixelRatio = pixelRatio;
this.stretchX = stretchX;
this.stretchY = stretchY;
this.content = content;
this.version = version;
this.textFitWidth = textFitWidth;
this.textFitHeight = textFitHeight;
}

get tl(): [number, number] {
Expand Down
2 changes: 2 additions & 0 deletions src/render/image_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ export class ImageManager extends Evented {
stretchX: image.stretchX,
stretchY: image.stretchY,
content: image.content,
textFitWidth: image.textFitWidth,
textFitHeight: image.textFitHeight,
hasRenderCallback: Boolean(image.userImage && image.userImage.render)
};
} else {
Expand Down
2 changes: 1 addition & 1 deletion src/render/update_pattern_positions_in_program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe('updatePatternPositionsInProgram', () => {
const tile = new Tile(new OverscaledTileID(3, 0, 2, 1, 2), undefined);
tile.imageAtlas = {} as any;
tile.imageAtlas.patternPositions = {
'volcano_11': {paddedRect: {x: 0, y: 0, w: 0, h: 0}, version: 0, tl: [0, 0], pixelRatio: 1, br: [0, 0], tlbr: [0, 0, 0, 0], displaySize: [0, 0], stretchX: [], stretchY: [], content: [0, 0, 0, 0]},
'volcano_11': {paddedRect: {x: 0, y: 0, w: 0, h: 0}, version: 0, tl: [0, 0], pixelRatio: 1, br: [0, 0], tlbr: [0, 0, 0, 0], displaySize: [0, 0], stretchX: [], stretchY: [], content: [0, 0, 0, 0], textFitWidth: undefined, textFitHeight: undefined},
};
const crossFadeResolveImage: CrossFaded<ResolvedImage> = {
from: {name: 'zoo_11', available: false, toString: () => 'zoo_11'},
Expand Down
4 changes: 2 additions & 2 deletions src/style/load_sprite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ async function doOnceCompleted(
const json = (await jsonsMap[spriteName]).data;

for (const id in json) {
const {width, height, x, y, sdf, pixelRatio, stretchX, stretchY, content} = json[id];
const {width, height, x, y, sdf, pixelRatio, stretchX, stretchY, content, textFitWidth, textFitHeight} = json[id];
const spriteData = {width, height, x, y, context};
result[spriteName][id] = {data: null, pixelRatio, sdf, stretchX, stretchY, content, spriteData};
result[spriteName][id] = {data: null, pixelRatio, sdf, stretchX, stretchY, content, textFitWidth, textFitHeight, spriteData};
}
}

Expand Down
29 changes: 29 additions & 0 deletions src/style/style_image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ export type StyleImageData = {
spriteData?: SpriteOnDemandStyleImage;
};

/**
* Enumeration of possible values for StyleImageMetadata.textFitWidth and textFitHeight.
*/
export const enum TextFit {
/**
* The image will be resized on the specified axis to tightly fit the content rectangle to target text.
* This is the same as not being defined.
*/
stretchOrShrink = 'stretchOrShrink',
/**
* The image will be resized on the specified axis to fit the content rectangle to the target text, but will not
* fall below the aspect ratio of the original content rectangle if the other axis is set to proportional.
*/
stretchOnly = 'stretchOnly',
/**
* The image will be resized on the specified axis to fit the content rectangle to the target text and
* will resize the other axis to maintain the aspect ratio of the content rectangle.
*/
proportional = 'proportional'
}

/**
* The style's image metadata
*/
Expand All @@ -55,6 +76,14 @@ export type StyleImageMetadata = {
* If `icon-text-fit` is used in a layer with this image, this option defines the part of the image that can be covered by the content in `text-field`.
*/
content?: [number, number, number, number];
/**
* If `icon-text-fit` is used in a layer with this image, this option defines constraints on the horizontal scaling of the image.
*/
textFitWidth?: TextFit;
/**
* If `icon-text-fit` is used in a layer with this image, this option defines constraints on the vertical scaling of the image.
*/
textFitHeight?: TextFit;
};

/**
Expand Down
44 changes: 27 additions & 17 deletions src/symbol/collision_feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {CollisionBoxArray} from '../data/array_types.g';
import Point from '@mapbox/point-geometry';
import type {Anchor} from './anchor';
import {SymbolPadding} from '../style/style_layer/symbol_style_layer';
import {applyTextFit} from './shaping';

/**
* A CollisionFeature represents the area of the tile covered by a single label.
Expand Down Expand Up @@ -57,29 +58,38 @@ export class CollisionFeature {
this.circleDiameter = height;
}
} else {
const icon = shaped.image?.content && (shaped.image.textFitWidth || shaped.image.textFitHeight) ?
applyTextFit(shaped) :
{
x1: shaped.left,
y1: shaped.top,
x2: shaped.right,
y2: shaped.bottom
};

// margin is in CSS order: [top, right, bottom, left]
let y1 = shaped.top * boxScale - padding[0];
let y2 = shaped.bottom * boxScale + padding[2];
let x1 = shaped.left * boxScale - padding[3];
let x2 = shaped.right * boxScale + padding[1];
icon.y1 = icon.y1 * boxScale - padding[0];
icon.y2 = icon.y2 * boxScale + padding[2];
icon.x1 = icon.x1 * boxScale - padding[3];
icon.x2 = icon.x2 * boxScale + padding[1];

const collisionPadding = shaped.collisionPadding;
if (collisionPadding) {
x1 -= collisionPadding[0] * boxScale;
y1 -= collisionPadding[1] * boxScale;
x2 += collisionPadding[2] * boxScale;
y2 += collisionPadding[3] * boxScale;
icon.x1 -= collisionPadding[0] * boxScale;
icon.y1 -= collisionPadding[1] * boxScale;
icon.x2 += collisionPadding[2] * boxScale;
icon.y2 += collisionPadding[3] * boxScale;
}

if (rotate) {
// Account for *-rotate in point collision boxes
// See https://github.com/mapbox/mapbox-gl-js/issues/6075
// Doesn't account for icon-text-fit

const tl = new Point(x1, y1);
const tr = new Point(x2, y1);
const bl = new Point(x1, y2);
const br = new Point(x2, y2);
const tl = new Point(icon.x1, icon.y1);
const tr = new Point(icon.x2, icon.y1);
const bl = new Point(icon.x1, icon.y2);
const br = new Point(icon.x2, icon.y2);

const rotateRadians = rotate * Math.PI / 180;

Expand All @@ -91,12 +101,12 @@ export class CollisionFeature {
// Collision features require an "on-axis" geometry,
// so take the envelope of the rotated geometry
// (may be quite large for wide labels rotated 45 degrees)
x1 = Math.min(tl.x, tr.x, bl.x, br.x);
x2 = Math.max(tl.x, tr.x, bl.x, br.x);
y1 = Math.min(tl.y, tr.y, bl.y, br.y);
y2 = Math.max(tl.y, tr.y, bl.y, br.y);
icon.x1 = Math.min(tl.x, tr.x, bl.x, br.x);
icon.x2 = Math.max(tl.x, tr.x, bl.x, br.x);
icon.y1 = Math.min(tl.y, tr.y, bl.y, br.y);
icon.y2 = Math.max(tl.y, tr.y, bl.y, br.y);
}
collisionBoxArray.emplaceBack(anchor.x, anchor.y, x1, y1, x2, y2, featureIndex, sourceLayerIndex, bucketIndex);
collisionBoxArray.emplaceBack(anchor.x, anchor.y, icon.x1, icon.y1, icon.x2, icon.y2, featureIndex, sourceLayerIndex, bucketIndex);
}

this.boxEndIndex = collisionBoxArray.length;
Expand Down
35 changes: 25 additions & 10 deletions src/symbol/quads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import Point from '@mapbox/point-geometry';
import {GLYPH_PBF_BORDER} from '../style/parse_glyph_pbf';

import type {Anchor} from './anchor';
import type {PositionedIcon, Shaping} from './shaping';
import {SHAPING_DEFAULT_OFFSET} from './shaping';
import type {Box, PositionedIcon, Shaping} from './shaping';
import {SHAPING_DEFAULT_OFFSET, applyTextFit} from './shaping';
import {IMAGE_PADDING} from '../render/image_atlas';
import type {SymbolStyleLayer} from '../style/style_layer/symbol_style_layer';
import type {Feature} from '@maplibre/maplibre-gl-style-spec';
Expand Down Expand Up @@ -65,8 +65,12 @@ export function getIconQuads(
const imageWidth = image.paddedRect.w - 2 * border;
const imageHeight = image.paddedRect.h - 2 * border;

const iconWidth = shapedIcon.right - shapedIcon.left;
const iconHeight = shapedIcon.bottom - shapedIcon.top;
let icon: Box = {
x1: shapedIcon.left,
y1: shapedIcon.top,
x2: shapedIcon.right,
y2: shapedIcon.bottom
};

const stretchX = image.stretchX || [[0, imageWidth]];
const stretchY = image.stretchY || [[0, imageHeight]];
Expand All @@ -88,28 +92,39 @@ export function getIconQuads(

if (image.content && hasIconTextFit) {
const content = image.content;
const contentWidth = content[2] - content[0];
const contentHeight = content[3] - content[1];
// Constrict content area to fit target aspect ratio
if (image.textFitWidth || image.textFitHeight) {
icon = applyTextFit(shapedIcon);
}
stretchOffsetX = sumWithinRange(stretchX, 0, content[0]);
stretchOffsetY = sumWithinRange(stretchY, 0, content[1]);
stretchContentWidth = sumWithinRange(stretchX, content[0], content[2]);
stretchContentHeight = sumWithinRange(stretchY, content[1], content[3]);
fixedOffsetX = content[0] - stretchOffsetX;
fixedOffsetY = content[1] - stretchOffsetY;
fixedContentWidth = content[2] - content[0] - stretchContentWidth;
fixedContentHeight = content[3] - content[1] - stretchContentHeight;
fixedContentWidth = contentWidth - stretchContentWidth;
fixedContentHeight = contentHeight - stretchContentHeight;
}

const iconLeft = icon.x1;
const iconTop = icon.y1;
const iconWidth = icon.x2 - iconLeft;
const iconHeight = icon.y2 - iconTop;

const makeBox = (left, top, right, bottom) => {

const leftEm = getEmOffset(left.stretch - stretchOffsetX, stretchContentWidth, iconWidth, shapedIcon.left);
const leftEm = getEmOffset(left.stretch - stretchOffsetX, stretchContentWidth, iconWidth, iconLeft);
const leftPx = getPxOffset(left.fixed - fixedOffsetX, fixedContentWidth, left.stretch, stretchWidth);

const topEm = getEmOffset(top.stretch - stretchOffsetY, stretchContentHeight, iconHeight, shapedIcon.top);
const topEm = getEmOffset(top.stretch - stretchOffsetY, stretchContentHeight, iconHeight, iconTop);
const topPx = getPxOffset(top.fixed - fixedOffsetY, fixedContentHeight, top.stretch, stretchHeight);

const rightEm = getEmOffset(right.stretch - stretchOffsetX, stretchContentWidth, iconWidth, shapedIcon.left);
const rightEm = getEmOffset(right.stretch - stretchOffsetX, stretchContentWidth, iconWidth, iconLeft);
const rightPx = getPxOffset(right.fixed - fixedOffsetX, fixedContentWidth, right.stretch, stretchWidth);

const bottomEm = getEmOffset(bottom.stretch - stretchOffsetY, stretchContentHeight, iconHeight, shapedIcon.top);
const bottomEm = getEmOffset(bottom.stretch - stretchOffsetY, stretchContentHeight, iconHeight, iconTop);
const bottomPx = getPxOffset(bottom.fixed - fixedOffsetY, fixedContentHeight, bottom.stretch, stretchHeight);

const tl = new Point(leftEm, topEm);
Expand Down
112 changes: 112 additions & 0 deletions src/symbol/shaping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {PositionedIcon, applyTextFit, Box} from './shaping';
import {ImagePosition} from '../render/image_atlas';
import {StyleImage, TextFit} from '../style/style_image';

describe('applyTextFit', () => {

describe('applyTextFitHorizontal', () => {
// This set of tests against applyTextFit starts with a 100x20 image with a 5,5,95,15 content box
// that has been scaled to 4x4... resulting in a 14x14 image.
const left = 0;
const top = 0;
const right = 14;
const bottom = 14;
const rectangle = {x: 0, y: 0, w: 100, h: 20};
const content: [number, number, number, number] = [5, 5, 95, 15];

test('applyTextFit: not specified', async () => {
// No change should happen
const styleImage: StyleImage = {
pixelRatio: 1,
version: 1,
sdf: false,
content,
data: undefined!};
const shapedIcon: PositionedIcon = {
left,
top,
right,
bottom,
image: new ImagePosition(rectangle, styleImage),
};
const result: Box = applyTextFit(shapedIcon);
expect(result).toEqual({x1: 0, y1: 0, x2: 14, y2: 14});
});

test('applyTextFit: stretchOrShrink', async () => {
// No change should happen
const styleImage: StyleImage = {
pixelRatio: 1,
version: 1,
sdf: false,
content,
textFitWidth: TextFit.stretchOrShrink,
textFitHeight: TextFit.stretchOrShrink,
data: undefined!};
const shapedIcon: PositionedIcon = {
left,
top,
right,
bottom,
image: new ImagePosition(rectangle, styleImage),
};
const result: Box = applyTextFit(shapedIcon);
expect(result).toEqual({x1: 0, y1: 0, x2: 14, y2: 14});
});

test('applyTextFit: stretchOnly, proportional', async () => {
// Since textFitWidth is stretchOnly, it should be returned to
// the aspect ratio of the content rectangle (9:1) aspect ratio so 126x14.
const styleImage: StyleImage = {
pixelRatio: 1,
version: 1,
sdf: false,
content,
textFitWidth: TextFit.stretchOnly,
textFitHeight: TextFit.proportional,
data: undefined!};
const shapedIcon: PositionedIcon = {
left,
top,
right,
bottom,
image: new ImagePosition(rectangle, styleImage),
};
const result: Box = applyTextFit(shapedIcon);
expect(result).toEqual({x1: 0, y1: 0, x2: 126, y2: 14});
});
});

describe('applyTextFitVertical', () => {
// This set of tests against applyTextFit starts with a 20x100 image with a 5,5,15,95 content box
// that has been scaled to 4x4... resulting in a 14x14 image.
const left = 0;
const top = 0;
const right = 14;
const bottom = 14;
const rectangle = {x: 0, y: 0, w: 20, h: 100};
const content: [number, number, number, number] = [5, 5, 15, 95];

test('applyTextFit: proportional, stretchOnly', async () => {
// Since the rectangle is wider than tall, when it matches based on width (because that is proportional),
// then the height will stretch to match the content so we also get a 14x14 image.
const styleImage: StyleImage = {
pixelRatio: 1,
version: 1,
sdf: false,
content,
textFitWidth: TextFit.proportional,
textFitHeight: TextFit.stretchOnly,
data: undefined!};
const shapedIcon: PositionedIcon = {
left,
top,
right,
bottom,
image: new ImagePosition(rectangle, styleImage),
};
const result: Box = applyTextFit(shapedIcon);
expect(result).toEqual({x1: 0, y1: 0, x2: 14, y2: 126});
});
});
});
Loading

0 comments on commit e0f688a

Please sign in to comment.