8000 images: add composite image for Wikimedia Category by zbycz · Pull Request #1130 · zbycz/osmapp · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

images: add composite image for Wikimedia Category #1130

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jun 25, 2025
Merged
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
4 changes: 2 additions & 2 deletions src/services/images/__tests__/apiMocks.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ export const WIKIDATA: ApiMock = {
};

export const COMMONS_CATEGORY: ApiMock = {
url: 'https://commons.wikimedia.org/w/api.php?action=query&generator=categorymembers&gcmtitle=Category%3AYosemite%20National%20Park&gcmlimit=1&gcmtype=file&prop=imageinfo&&iiprop=url&iiurlwidth=410&format=json&origin=*',
url: 'https://commons.wikimedia.org/w/api.php?action=query&generator=categorymembers&gcmtitle=Category%3AYosemite%20National%20Park&gcmlimit=20&gcmtype=file&prop=imageinfo&iiprop=url&iiurlwidth=410&format=json&origin=*',
response: {
batchcomplete: '',
continue: {
gcmcontinue:
'file|3139313620594f53454d4954452042592047454f52474520535445524c494e472046524f4e5420434f5645522e504e47|149319876',
'file|35303050582050484f544f2028313039393139373931292e4a504547|78764811',
continue: 'gcmcontinue||',
},
query: {
Expand Down
11 changes: 9 additions & 2 deletions src/services/images/__tests__/getImageFromApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import {
WIKIPEDIA,
WIKIPEDIA_CS,
} from './apiMocks.fixture';
import * as makeCategoryImageModule from '../makeCategoryImage';

jest.mock('../makeCategoryImage', () => ({
makeCategoryImage: jest.fn(),
}));

jest.mock('../../fetch', () => ({
fetchJson: jest.fn(),
Expand Down Expand Up @@ -119,6 +124,9 @@ test('wikimedia_commons=File:', async () => {

test('wikimedia_commons=Category:', async () => {
mockApi(COMMONS_CATEGORY);
jest
.spyOn(makeCategoryImageModule, 'makeCategoryImage')
.mockResolvedValue('xyz');
expect(
await getImageFromApiRaw({
type: 'tag',
Expand All @@ -128,8 +136,7 @@ test('wikimedia_commons=Category:', async () => {
}),
).toEqual({
description: 'Wikimedia Commons category (wikimedia_commons:2=*)',
imageUrl:
'https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/1912_Indian_Motorcycle._This_two-cylinder_motorcycle_is_thought_to_have_been_the_first_motorcycle_in_Yosemite._The_driver_was_(ca6a33cc-1dd8-b71b-0b83-9551ada5207f).jpg/410px-thumbnail.jpg',
imageUrl: 'xyz',
link: 'Category:Yosemite National Park',
linkUrl:
'https://commons.wikimedia.org/wiki/Category:Yosemite National Park',
Expand Down
79 changes: 79 additions & 0 deletions src/services/images/__tests__/makeCategoryImage.test.ts
10000
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { placeImageToCanvas } from '../makeCategoryImage';

const extractXY = (call) => [call[5], call[6]];
const mockImage = (url: string) => {
const [width, height] = url.split('x').map(Number);
return { width, height } as unknown as HTMLImageElement;
};

describe('placeImageToCanvas', () => {
it('1 image', () => {
const images = ['410x120'].map(mockImage);
const ctxMock = { drawImage: jest.fn() };
placeImageToCanvas(images, ctxMock as unknown as CanvasRenderingContext2D);

expect(ctxMock.drawImage).toHaveBeenCalledWith(
{ height: 120, width: 410 },
129.36974789915968,
0,
151.26050420168067,
120,
0,
0,
300,
238,
);
});

it('1 column', () => {
const images = ['410x120', '410x150'].map(mockImage);
const ctxMock = { drawImage: jest.fn() };
placeImageToCanvas(images, ctxMock as unknown as CanvasRenderingContext2D);

expect(ctxMock.drawImage.mock.calls.map(extractXY)).toEqual([
[0, 0],
[0, 106.88888888888889],
]);
});

it('2 columns', () => {
const images = ['410x120', '410x150', '410x90'].map(mockImage);
const ctxMock = { drawImage: jest.fn() };
placeImageToCanvas(images, ctxMock as unknown as CanvasRenderingContext2D);

expect(ctxMock.drawImage.mock.calls.map(extractXY)).toEqual([
[0, 0],
[0, 136.8571428571429],
[151, 0],
]);
});

it('3 columns', () => {
const images = new Array(20).fill('410x90').map(mockImage);
const ctxMock = { drawImage: jest.fn() };
placeImageToCanvas(images, ctxMock as unknown as CanvasRenderingContext2D);

expect(ctxMock.drawImage.mock.calls.map(extractXY)).toEqual([
[0, 0],
[0, 34.285714285714285],
[0, 68.57142857142857],
[0, 102.85714285714286],
[0, 137.14285714285714],
[0, 171.42857142857142],
[0, 205.7142857142857],
[100.66666666666667, 0],
[100.66666666666667, 34.285714285714285],
[100.66666666666667, 68.57142857142857],
[100.66666666666667, 102.85714285714286],
[100.66666666666667, 137.14285714285714],
[100.66666666666667, 171.42857142857142],
[100.66666666666667, 205.7142857142857],
[201.33333333333334, 0],
[201.33333333333334, 40],
[201.33333333333334, 80],
[201.33333333333334, 120],
[201.33333333333334, 160],
[201.33333333333334, 200],
]);
});
});
26 changes: 18 additions & 8 deletions src/services/images/getImageFromApi.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { fetchJson } from '../fetch';
import { isInstant, ImageDef, isCenter, isTag } from '../types';
import { ImageDef, isCenter, isInstant, isTag } from '../types';
import { getMapillaryImage, MAPILLARY_ACCESS_TOKEN } from './getMapillaryImage';
import { getFodyImage } from './getFodyImage';
import { getInstantImage, WIDTH, ImageType } from './getImageDefs';
import { getInstantImage, ImageType, WIDTH } from './getImageDefs';
import { encodeUrl } from '../../helpers/utils';
import { getCommonsImageUrl } from './getCommonsImageUrl';
import { getKartaViewImage } from './getkartaViewImage';
import { getPanoramaxImage } from './getPanoramaxImage';
import { makeCategoryImage } from './makeCategoryImage';

type ImagePromise = Promise<ImageType | null>;

Expand All @@ -30,20 +31,29 @@ const fetchCommonsFile = async (k: string, v: string): ImagePromise => {
};
};

// TODO perhaps fetch more images, or create a collage of first few images
const isAudioUrl = (url: string) =>
url.endsWith('.ogg') || url.endsWith('.mp3') || url.endsWith('.wav');

const getCommonsCategoryApiUrl = (title: string) =>
encodeUrl`https://commons.wikimedia.org/w/api.php?action=query&generator=categorymembers&gcmtitle=${title}&gcmlimit=1&gcmtype=file&prop=imageinfo&&iiprop=url&iiurlwidth=${WIDTH}&format=json&origin=*`;
encodeUrl`https://commons.wikimedia.org/w/api.php?action=query&generator=categorymembers&gcmtitle=${title}&gcmlimit=20&gcmtype=file&prop=imageinfo&iiprop=url&iiurlwidth=${WIDTH}&format=json&origin=*`;

const fetchCommonsCategory = async (k: string, v: string): ImagePromise => {
const url = getCommonsCategoryApiUrl(v);
const data = await fetchJson(url);
const page = Object.values(data.query.pages)[0] as any;
if (!page.imageinfo?.length) {
const pages = Object.values(data.query.pages);
const imageInfos = pages
.map((page: any) => page.imageinfo?.[0])
.filter(Boolean);
const thumbs = imageInfos
.filter(({ url }) => !isAudioUrl(url))
.map(({ thumburl }) => thumburl);
const imageUrl = await makeCategoryImage(thumbs);
if (!imageUrl) {
return null;
}
const image = page.imageinfo[0];

return {
imageUrl: decodeURI(image.thumburl),
imageUrl,
description: `Wikimedia Commons category (${k}=*)`,
link: v,
linkUrl: `https://commons.wikimedia.org/wiki/${v}`,
Expand Down
137 changes: 137 additions & 0 deletions src/services/images/makeCategoryImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
const retina = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
const WIDTH = 300 * retina;
const HEIGHT = 238 * retina;
const PADDING = 2 * retina;

const loadImage = (url: string) =>
new Promise<HTMLImageElement>((resolve) => {
const img = new Image();
img.crossOrigin = 'Anonymous';
img. => resolve(img);
img. => resolve(null);
img.src = url;
});

const loadImages = async (thumbsUrls: string[]) => {
const images = await Promise.all(thumbsUrls.map(loadImage));
return images.filter((item) => item && item.width > 0 && item.height > 0);
};

const findMinimalHeightColumn = (columnHeights: number[]) => {
let minHeight = Infinity;
let targetCol = 0;
for (let i = 0; i < columnHeights.length; i++) {
if (columnHeights[i] < minHeight) {
minHeight = columnHeights[i];
targetCol = i;
}
}
return targetCol;
};

type ColumnItem = { img: HTMLImageElement; height: number };

const computeCoverEffect = (
item: ColumnItem,
targetWidth: number,
targetHeight: number,
) => {
const imgAspect = item.img.width / item.img.height;
const cellAspect = targetWidth / targetHeight;
let sx = 0,
sy = 0,
sw = item.img.width,
sh = item.img.height;

if (imgAspect > cellAspect) {
sw = item.img.height * cellAspect;
sx = (item.img.width - sw) / 2;
} else {
sh = item.img.width / cellAspect;
sy = (item.img.height - sh) / 2;
}
return { sx, sy, sw, sh }; // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
};

export const placeImageToCanvas = (
images: HTMLImageElement[],
ctx: CanvasRenderingContext2D,
) => {
const numCols = images.length >= 8 ? 3 : images.length >= 3 ? 2 : 1;
const columnWidth = (WIDTH - (numCols - 1) * PADDING) / numCols;

const columnContents: ColumnItem[][] = Array(numCols)
.fill(null)
.map(() => []);
const columnHeights: number[] = Array(numCols).fill(0);

// we do classic masonry here
images.forEach((img) => {
const height = img.height * (columnWidth / img.width);
const targetCol = findMinimalHeightColumn(columnHeights);
const offsetY = columnHeights[targetCol];

if (offsetY + height <= HEIGHT) {
columnContents[targetCol].push({ img, height });
columnHeights[targetCol] += height;
}
});

// scale each image in colum to all available height
for (let col = 0; col < numCols; col++) {
const contentHeight = columnHeights[col];
const totalYPadding = (columnContents[col].length - 1) * PADDING;
const availibleHeight = HEIGHT - totalYPadding;

let offsetY = 0;
columnContents[col].forEach((item) => {
const drawX = col * (columnWidth + PADDING);
const cellHeight = (item.height / contentHeight) * availibleHeight;

const { sx, sy, sw, sh } = computeCoverEffect(
item,
columnWidth,
cellHeight,
);

ctx.drawImage(
item.img,
sx,
sy,
sw,
sh,
drawX,
offsetY,
columnWidth,
cellHeight,
);

offsetY += cellHeight + PADDING;
});
}
};

export const makeCategoryImage = async (
thumbsUrls: string[],
): Promise<string> => {
const canvas = document.createElement('canvas');
canvas.width = WIDTH;
canvas.height = HEIGHT;

const ctx = canvas.getContext('2d');
if (!ctx) {
return null;
}

ctx.fillStyle = '#f0f0f0';
ctx.fillRect(0, 0, WIDTH, HEIGHT);

const validImages = await loadImages(thumbsUrls);
if (validImages.length === 0) {
return null;
}

placeImageToCanvas(validImages, ctx);

return canvas.toDataURL('image/jpeg', 0.8);
};
0