Skip to content
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

Feat: IconApi #335

Merged
merged 36 commits into from
Nov 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
bef3c5c
add initial Implementation of `dataStore.writeRaw`
Oct 9, 2023
a3b80b5
add `dataStore.readRaw` and test
Oct 9, 2023
6169575
remove `swap16` to avoid linting errors
Oct 9, 2023
1fb8482
add IconApi class
Oct 9, 2023
2a4e65c
remove unused var, ignore ts error to make stuff work
Oct 9, 2023
a7460b1
add check for invalid variants array, add test
Oct 9, 2023
eb424a5
change `readRaw` error to 'core not found'
Oct 9, 2023
70c8f8a
Merge branch 'feat/dataStore.writeRaw' of github.com:digidem/mapeo-co…
Oct 9, 2023
cbee371
Trying to make tests work
Oct 9, 2023
7c19937
Merge branch 'main' of github.com:digidem/mapeo-core-next into feat/I…
Oct 9, 2023
274abcd
update mapeo-schema, make tests work
Oct 10, 2023
84bba41
implement getBestVariant
Oct 10, 2023
b1022af
more tests, fix implementation to actually choose last with best score
Oct 10, 2023
16a350a
return `mimeType` along `icon` in iconApi.getIcon
Oct 10, 2023
87072f2
fix tests
Oct 10, 2023
4293a86
various stuff:
Oct 20, 2023
00b9381
in case of tie, choose first best score, comment failing test
Oct 20, 2023
285565f
add test to `getIconUrl`
Oct 20, 2023
8d41e3a
added tests to find best variant, finished `findBestVariant` impl
Oct 25, 2023
466f7ff
Merge branch 'main' of github.com:digidem/mapeo-core-next into feat/I…
Oct 25, 2023
0d089e5
Various stuff:
Oct 26, 2023
b6eedc2
Merge branch 'main' of github.com:digidem/mapeo-core-next into feat/I…
Oct 26, 2023
6ec534d
Merge branch 'main' into feat/IconApi
achou11 Oct 26, 2023
34551a2
add more extensive tests for getBestVariant
achou11 Oct 26, 2023
5a39fe7
get new tests passing
achou11 Oct 30, 2023
2a866d1
use discriminate union based on mime type for getBestVariant() opts
achou11 Oct 31, 2023
6a68388
avoid using ts-expect-error in tests
achou11 Oct 31, 2023
76eeb5c
add a couple of more tests (skipping one of them)
achou11 Oct 31, 2023
75f7d77
clean up getBestVariant() implementation
achou11 Oct 31, 2023
8f68124
update getIconUrl implementation
achou11 Oct 31, 2023
fe37d06
do a setup for each test
achou11 Oct 31, 2023
a210782
update create() to use discriminate union for variants param
achou11 Oct 31, 2023
a263200
update return types and clean up tests
achou11 Oct 31, 2023
45438d7
add 'x' after pixel density in getIconUrl()
achou11 Oct 31, 2023
a08c420
Merge branch 'main' into feat/IconApi
achou11 Oct 31, 2023
c972987
add getMediaBaseUrl constructor opt and update getIconUrl()
achou11 Nov 1, 2023
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
243 changes: 243 additions & 0 deletions src/icon-api.js
achou11 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
export const kGetIconBlob = Symbol('getIcon')

/** @typedef {import('@mapeo/schema').IconValue['variants']} IconVariants */
/** @typedef {IconVariants[number]} IconVariant */

/**
* @typedef {Object} BitmapOpts
* @property {Extract<IconVariant['mimeType'], 'image/png'>} mimeType
* @property {IconVariant['pixelDensity']} pixelDensity
* @property {IconVariant['size']} size
*
* @typedef {Object} SvgOpts
* @property {Extract<IconVariant['mimeType'], 'image/svg+xml'>} mimeType
* @property {IconVariant['size']} size
*/

/** @type {{ [mime in IconVariant['mimeType']]: string }} */
const MIME_TO_EXTENSION = {
'image/png': '.png',
'image/svg+xml': '.svg',
}

export class IconApi {
#projectId
#dataType
#dataStore
#getMediaBaseUrl

/**
* @param {Object} opts
* @param {import('./datatype/index.js').DataType<
* import('./datastore/index.js').DataStore<'config'>,
* typeof import('./schema/project.js').iconTable,
* 'icon',
* import('@mapeo/schema').Icon,
* import('@mapeo/schema').IconValue
* >} opts.iconDataType
* @param {import('./datastore/index.js').DataStore<'config'>} opts.iconDataStore
* @param {string} opts.projectId
* @param {() => Promise<string>} opts.getMediaBaseUrl
*/
constructor({ iconDataType, iconDataStore, projectId, getMediaBaseUrl }) {
this.#dataType = iconDataType
this.#dataStore = iconDataStore
this.#projectId = projectId
this.#getMediaBaseUrl = getMediaBaseUrl
}

/**
* @param {object} icon
* @param {import('@mapeo/schema').IconValue['name']} icon.name
* @param {Array<(BitmapOpts | SvgOpts) & { blob: Buffer }>} icon.variants
*
* @returns {Promise<string>}
*/
async create(icon) {
tomasciccola marked this conversation as resolved.
Show resolved Hide resolved
if (icon.variants.length < 1) {
throw new Error('empty variants array')
}

const savedVariants = await Promise.all(
icon.variants.map(async ({ blob, ...variant }) => {
const blobVersionId = await this.#dataStore.writeRaw(blob)

return {
...variant,
blobVersionId,
pixelDensity:
// Pixel density does not apply to svg variants
// TODO: Ideally @mapeo/schema wouldn't require pixelDensity when the mime type is svg
variant.mimeType === 'image/svg+xml'
? /** @type {const} */ (1)
: variant.pixelDensity,
}
})
)

const { docId } = await this.#dataType.create({
schemaName: 'icon',
name: icon.name,
variants: savedVariants,
})

return docId
}

/**
* @param {string} iconId
* @param {BitmapOpts | SvgOpts} opts
*
* @returns {Promise<Buffer>}
*/
async [kGetIconBlob](iconId, opts) {
const iconRecord = await this.#dataType.getByDocId(iconId)
const iconVariant = getBestVariant(iconRecord.variants, opts)
const blob = await this.#dataStore.readRaw(iconVariant.blobVersionId)
return blob
}

/**
* @param {string} iconId
* @param {BitmapOpts | SvgOpts} opts
*
* @returns {Promise<string>}
*/
async getIconUrl(iconId, opts) {
let base = await this.#getMediaBaseUrl()

if (!base.endsWith('/')) {
base += '/'
}

base += `${this.#projectId}/${iconId}/`

const mimeExtension = MIME_TO_EXTENSION[opts.mimeType]

if (opts.mimeType === 'image/svg+xml') {
return base + `${opts.size}${mimeExtension}`
}

return base + `${opts.size}@${opts.pixelDensity}x${mimeExtension}`
}
}

/**
* @type {Record<IconVariant['size'], number>}
*/
const SIZE_AS_NUMERIC = {
small: 1,
medium: 2,
large: 3,
}

/**
* Given a list of icon variants returns the variant that most closely matches the desired parameters.
* Rules, in order of precedence:
*
* 1. Matching mime type (throw if no matches)
* 2. Matching size. If no exact match:
* 1. If smaller ones exist, prefer closest smaller size.
* 2. Otherwise prefer closest larger size.
* 3. Matching pixel density. If no exact match:
* 1. If smaller ones exist, prefer closest smaller density.
* 2. Otherwise prefer closest larger density.
*
* @param {IconVariants} variants
* @param {BitmapOpts | SvgOpts} opts
*/
export function getBestVariant(variants, opts) {
const { size: wantedSize, mimeType: wantedMimeType } = opts
// Pixel density doesn't matter for svg so default to 1
const wantedPixelDensity =
opts.mimeType === 'image/svg+xml' ? 1 : opts.pixelDensity

if (variants.length === 0) {
throw new Error('No variants exist')
}

const matchingMime = variants.filter((v) => v.mimeType === wantedMimeType)

if (matchingMime.length === 0) {
throw new Error(
`No variants with desired mime type ${wantedMimeType} exist`
)
}

const wantedSizeNum = SIZE_AS_NUMERIC[wantedSize]

// Sort the relevant variants based on the desired size and pixel density, using the rules of the preference.
// Sorted from closest match to furthest match.
matchingMime.sort((a, b) => {
const aSizeNum = SIZE_AS_NUMERIC[a.size]
const bSizeNum = SIZE_AS_NUMERIC[b.size]

const aSizeDiff = aSizeNum - wantedSizeNum
const bSizeDiff = bSizeNum - wantedSizeNum

// Both variants match desired size, use pixel density to determine preferred match
if (aSizeDiff === 0 && bSizeDiff === 0) {
// Pixel density doesn't matter for svg but prefer lower for consistent results
if (opts.mimeType === 'image/svg+xml') {
return a.pixelDensity <= b.pixelDensity ? -1 : 1
}

return determineSortValue(
wantedPixelDensity,
a.pixelDensity,
b.pixelDensity
)
}

return determineSortValue(wantedSizeNum, aSizeNum, bSizeNum)
})

// Closest match will be first element
return matchingMime[0]
}

/**
* Determines a sort value based on the order of precedence outlined below. Winning value moves closer to front.
*
* 1. Exactly match `target`
* 2. Closest value smaller than `target`
* 3. Closest value larger than `target`
*
* @param {number} target
* @param {number} a
* @param {number} b
*
* @returns {-1 | 0 | 1}
*/
function determineSortValue(target, a, b) {
const aDiff = a - target
const bDiff = b - target

// Both match exactly, don't change sort order
if (aDiff === 0 && bDiff === 0) {
return 0
}

// a matches but b doesn't, prefer a
if (aDiff === 0 && bDiff !== 0) {
return -1
}

// b matches but a doesn't, prefer b
if (bDiff === 0 && aDiff !== 0) {
return 1
}

// Both are larger than desired, prefer smaller of the two
if (aDiff > 0 && bDiff > 0) {
return a < b ? -1 : 1
}

// Both are smaller than desired, prefer larger of the two
if (aDiff < 0 && bDiff < 0) {
return a < b ? 1 : -1
}

// Mix of smaller and larger than desired, prefer smaller of the two
return a < b ? -1 : 1
}
Loading
Loading