Skip to content

Commit 7750e9a

Browse files
authored
Add Image component (#174)
Adds support for embedding images in Mafs views. ![](https://github.com/user-attachments/assets/f2d19d91-fdcc-4672-9493-b8a62c53f7b9)
1 parent 36fc4ae commit 7750e9a

24 files changed

+316
-9
lines changed

.api-report/mafs.api.md

+23-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
55
```ts
66

7-
/// <reference types="react" />
8-
97
import { JSX as JSX_2 } from 'react/jsx-runtime';
108
import { KatexOptions } from 'katex';
119
import * as React_2 from 'react';
@@ -82,6 +80,29 @@ export interface Filled {
8280
weight?: number;
8381
}
8482

83+
// @public (undocumented)
84+
function Image_2({ href, x, y, width, height, anchor, preserveAspectRatio, svgImageProps, }: ImageProps): JSX_2.Element;
85+
export { Image_2 as Image }
86+
87+
// @public (undocumented)
88+
export interface ImageProps {
89+
// Warning: (ae-forgotten-export) The symbol "Anchor" needs to be exported by the entry point index.d.ts
90+
anchor?: Anchor;
91+
// (undocumented)
92+
height: number;
93+
// (undocumented)
94+
href: string;
95+
preserveAspectRatio?: string;
96+
// (undocumented)
97+
svgImageProps?: React.SVGProps<SVGImageElement>;
98+
// (undocumented)
99+
width: number;
100+
// (undocumented)
101+
x: number;
102+
// (undocumented)
103+
y: number;
104+
}
105+
85106
// @public (undocumented)
86107
export type Interval = [min: number, max: number];
87108

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { PropTable } from "components/PropTable"
2+
3+
import CodeAndExample from "components/CodeAndExample"
4+
import ImageExample from "guide-examples/display/images/ImageExample"
5+
import ImageAnchorExample from "guide-examples/display/images/ImageAnchorExample"
6+
import type { Metadata } from "next"
7+
8+
export const metadata: Metadata = {
9+
title: "Images",
10+
}
11+
12+
function Images() {
13+
return (
14+
<>
15+
<p>
16+
Images in Mafs are just wrappers around the SVG <code>image</code> element, with some
17+
quality of life improvements tacked on (see below).
18+
</p>
19+
20+
<CodeAndExample example={ImageExample} />
21+
22+
<PropTable of={"Image"} />
23+
24+
<h2>
25+
Comparison with SVG <code>&lt;image&gt;</code>
26+
</h2>
27+
28+
<p>
29+
The SVG <code>image</code> element is a low-level way to include external images in an SVG.
30+
It has a few downsides:
31+
</p>
32+
33+
<ul>
34+
<li>Negative widths and heights lead to undefined behavior.</li>
35+
<li>
36+
The x and y attributes correspond to the top left of the image and is not configurable.
37+
</li>
38+
</ul>
39+
40+
<p>
41+
Mafs handles negative heights and widths the way you'd expect; by making the image grow in
42+
the <code>-x</code> and <code>-y</code> directions.
43+
</p>
44+
45+
<p>
46+
Additionally, the <code>anchor</code> attribute of <code>Image</code> allows you to declare
47+
whether the image's x and y coordinates refer to the corners, center of edges, or center of
48+
the image.
49+
</p>
50+
51+
<CodeAndExample example={ImageAnchorExample} />
52+
</>
53+
)
54+
}
55+
56+
export default Images

docs/app/guides/guides.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
TextIcon,
1111
CursorArrowIcon,
1212
PlayIcon,
13+
ImageIcon,
1314
} from "@radix-ui/react-icons"
1415

1516
import {
@@ -62,6 +63,7 @@ export const Guides: Section[] = [
6263
{ title: "Plots", icon: FunctionIcon, slug: "plots" },
6364
{ title: "Text", icon: TextIcon, slug: "text" },
6465
{ title: "Vectors", icon: ArrowTopRightIcon, slug: "vectors" },
66+
{ title: "Images", icon: ImageIcon, slug: "images" },
6567
{ separator: true },
6668
{ title: "Transform", icon: RotateCounterClockwiseIcon, slug: "transform" },
6769
{ title: "Debug", icon: DebugIcon, slug: "debug" },

docs/components/PropTable.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,9 @@ function PropType({ prop }: { prop: DocgenProp }) {
149149
return (
150150
<div className="flex flex-col gap-1">
151151
{prop.description && (
152-
<p className="text-gray-800 dark:text-slate-200 markdown">
152+
<div className="text-gray-800 dark:text-slate-200 markdown">
153153
<ReactMarkdown>{prop.description}</ReactMarkdown>
154-
</p>
154+
</div>
155155
)}
156156
<div className="text-gray-600 dark:text-slate-400">{typeNode}</div>
157157
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"use client"
2+
3+
import {
4+
Coordinates,
5+
Image,
6+
Mafs,
7+
useMovablePoint,
8+
} from "mafs"
9+
10+
import image from "./mafs.png"
11+
12+
export default function VectorExample() {
13+
const center = useMovablePoint([2, 2])
14+
return (
15+
<Mafs viewBox={{ x: [-1, 7], y: [-1, 5] }}>
16+
<Coordinates.Cartesian />
17+
<Image
18+
href={image.src ?? image}
19+
anchor="tl"
20+
x={center.x + 0.1}
21+
y={center.y - 0.1}
22+
width={1}
23+
height={1}
24+
/>
25+
<Image
26+
href={image.src ?? image}
27+
anchor="tr"
28+
x={center.x - 0.1}
29+
y={center.y - 0.1}
30+
width={1}
31+
height={1}
32+
/>
33+
<Image
34+
href={image.src ?? image}
35+
anchor="bl"
36+
x={center.x + 0.1}
37+
y={center.y + 0.1}
38+
width={1}
39+
height={1}
40+
/>
41+
<Image
42+
href={image.src ?? image}
43+
anchor="br"
44+
x={center.x - 0.1}
45+
y={center.y + 0.1}
46+
width={1}
47+
height={1}
48+
/>
49+
{center.element}
50+
</Mafs>
51+
)
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"use client"
2+
3+
import {
4+
Mafs,
5+
Image,
6+
Coordinates,
7+
useMovablePoint,
8+
} from "mafs"
9+
10+
import image from "./mafs.png"
11+
12+
export default function ImageExample() {
13+
const origin = useMovablePoint([1, 1])
14+
15+
return (
16+
<Mafs viewBox={{ x: [-1, 7], y: [-1, 5] }}>
17+
<Coordinates.Cartesian />
18+
<Image
19+
href={image.src ?? image}
20+
anchor="bl"
21+
x={origin.x}
22+
y={origin.y}
23+
width={2}
24+
height={2}
25+
/>
26+
{origin.element}
27+
</Mafs>
28+
)
29+
}
Loading

e2e/generated-vrt.spec.tsx

+20
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import SimpleTransform from "../docs/components/guide-examples/utility/SimpleTra
4141
import CartesianCoordinatesConfigExample from "../docs/components/guide-examples/display/coordinates/CartesianCoordinatesConfigExample"
4242
import CartesianCoordinatesExample from "../docs/components/guide-examples/display/coordinates/CartesianCoordinatesExample"
4343
import PolarCoordinatesExample from "../docs/components/guide-examples/display/coordinates/PolarCoordinatesExample"
44+
import ImageAnchorExample from "../docs/components/guide-examples/display/images/ImageAnchorExample"
45+
import ImageExample from "../docs/components/guide-examples/display/images/ImageExample"
4446
import VectorExample from "../docs/components/guide-examples/display/vectors/VectorExample"
4547
import ContainViewbox from "../docs/components/guide-examples/display/viewbox/ContainViewbox"
4648
import StretchViewbox from "../docs/components/guide-examples/display/viewbox/StretchViewbox"
@@ -381,6 +383,24 @@ test("guide-examples/display/coordinates/PolarCoordinatesExample", async ({ moun
381383
</TestContextProvider>,
382384
))
383385

386+
test("guide-examples/display/images/ImageAnchorExample", async ({ mount, page }) =>
387+
await visualTest(
388+
mount,
389+
page,
390+
<TestContextProvider value={{ overrideHeight: 500 }}>
391+
<ImageAnchorExample />
392+
</TestContextProvider>,
393+
))
394+
395+
test("guide-examples/display/images/ImageExample", async ({ mount, page }) =>
396+
await visualTest(
397+
mount,
398+
page,
399+
<TestContextProvider value={{ overrideHeight: 500 }}>
400+
<ImageExample />
401+
</TestContextProvider>,
402+
))
403+
384404
test("guide-examples/display/vectors/VectorExample", async ({ mount, page }) =>
385405
await visualTest(
386406
mount,

src/display/Image.tsx

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Anchor, computeAnchor } from "../math"
2+
3+
export interface ImageProps {
4+
href: string
5+
x: number
6+
y: number
7+
/**
8+
* Indicate where, in the image (top, bottom, left, right, center), the x and
9+
* y coordinate refers to.
10+
*/
11+
anchor?: Anchor
12+
width: number
13+
height: number
14+
/**
15+
* Whether to preserve the aspect ratio of the image. By default, the image
16+
* will be centered and scaled to fit the width and height. If you want to
17+
* squish the image to be the same shape as the box, set this to "none".
18+
*
19+
* This is passed directly to the `preserveAspectRatio` attribute of the SVG
20+
* `<image>` element.
21+
*
22+
* See [preserveAspectRatio](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/preserveAspectRatio) on MDN.
23+
*/
24+
preserveAspectRatio?: string
25+
26+
svgImageProps?: React.SVGProps<SVGImageElement>
27+
}
28+
29+
export function Image({
30+
href,
31+
x,
32+
y,
33+
width,
34+
height,
35+
anchor = "bl",
36+
preserveAspectRatio,
37+
svgImageProps,
38+
}: ImageProps) {
39+
const [anchorX, anchorY] = computeAnchor(anchor, x, y, width, height)
40+
41+
const transform = [
42+
"var(--mafs-view-transform)",
43+
"var(--mafs-user-transform)",
44+
// Ensure the image is not upside down (since Mafs has the y-axis pointing
45+
// up, while SVG has it pointing down).
46+
"scaleY(-1)",
47+
].join(" ")
48+
49+
return (
50+
<image
51+
href={href}
52+
x={anchorX}
53+
y={-anchorY}
54+
width={width}
55+
height={height}
56+
preserveAspectRatio={preserveAspectRatio}
57+
{...svgImageProps}
58+
style={{ transform }}
59+
/>
60+
)
61+
}

src/index.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ export type { PointProps } from "./display/Point"
3535
export { Vector } from "./display/Vector"
3636
export type { VectorProps } from "./display/Vector"
3737

38+
export { Image } from "./display/Image"
39+
export type { ImageProps } from "./display/Image"
40+
3841
export { Text } from "./display/Text"
3942
export type { TextProps, CardinalDirection } from "./display/Text"
4043

src/math.ts

+57
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export type Interval = [min: number, max: number]
2+
export type Anchor = "tl" | "tc" | "tr" | "cl" | "cc" | "cr" | "bl" | "bc" | "br"
23

34
export function round(value: number, precision = 0): number {
45
const multiplier = Math.pow(10, precision || 0)
@@ -24,3 +25,59 @@ export function range(min: number, max: number, step = 1): number[] {
2425
export function clamp(number: number, min: number, max: number): number {
2526
return Math.min(Math.max(number, min), max)
2627
}
28+
29+
/**
30+
* Given an anchor and a bounding box (x, y, width, height), compute the x and y coordinates of the
31+
* anchor such that rendering an element at those coordinates will align the element with the anchor.
32+
*/
33+
export function computeAnchor(
34+
anchor: Anchor,
35+
x: number,
36+
y: number,
37+
width: number,
38+
height: number,
39+
): [number, number] {
40+
let actualX = x
41+
let actualY = y
42+
43+
switch (anchor) {
44+
case "tl":
45+
actualX = x
46+
actualY = y
47+
break
48+
case "tc":
49+
actualX = x - width / 2
50+
actualY = y
51+
break
52+
case "tr":
53+
actualX = x - width
54+
actualY = y
55+
break
56+
case "cl":
57+
actualX = x
58+
actualY = y + height / 2
59+
break
60+
case "cc":
61+
actualX = x - width / 2
62+
actualY = y + height / 2
63+
break
64+
case "cr":
65+
actualX = x - width
66+
actualY = y + height / 2
67+
break
68+
case "bl":
69+
actualX = x
70+
actualY = y + height
71+
break
72+
case "bc":
73+
actualX = x - width / 2
74+
actualY = y + height
75+
break
76+
case "br":
77+
actualX = x - width
78+
actualY = y + height
79+
break
80+
}
81+
82+
return [actualX, actualY]
83+
}

src/typings/env.d.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
declare module "*.css" {
2+
const content: { [className: string]: string }
3+
export default content
4+
}
5+
6+
declare module "*.png" {
7+
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
8+
const content: any
9+
export default content
10+
}

src/typings/styles.d.ts

-4
This file was deleted.

0 commit comments

Comments
 (0)