Skip to content

Commit

Permalink
Merge branch 'main' into feat/initial-download
Browse files Browse the repository at this point in the history
* main:
  feat: add `$sync` API methods (#361)
  feat: `listLocalPeers()` & `local-peers` event (#360)
  feat: integrate LocalDiscovery & LocalPeers (#358)
  implement IconApi (#335)
  feat: MapeoRPC -> LocalPeers (#356)
  chore: update @mapeo/schema and @mapeo/sqlite-indexer (#357)
  • Loading branch information
gmaclennan committed Nov 9, 2023
2 parents 9921f66 + ca95b2f commit 6dfa0d6
Show file tree
Hide file tree
Showing 3 changed files with 930 additions and 2 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@
"@fastify/type-provider-typebox": "^3.3.0",
"@hyperswarm/secret-stream": "^6.1.2",
"@mapeo/crypto": "1.0.0-alpha.10",
"@mapeo/schema": "3.0.0-next.11",
"@mapeo/sqlite-indexer": "1.0.0-alpha.6",
"@mapeo/schema": "3.0.0-next.13",
"@mapeo/sqlite-indexer": "1.0.0-alpha.8",
"@sinclair/typebox": "^0.29.6",
"b4a": "^1.6.3",
"base32.js": "^0.1.0",
Expand Down
243 changes: 243 additions & 0 deletions src/icon-api.js
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) {
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

0 comments on commit 6dfa0d6

Please sign in to comment.