export const iiifServer = 'https://iiif.juncture-digital.org'
import { sha256 as __sha256 } from 'js-sha256'
import md5 from 'js-md5'
import tippy from 'tippy.js';
export function isURL(str:string) { return /^https*:\/\//.test(str) }
export function sha256(str: string) {
return __sha256(str)
}
export function isMobile() {
// console.log(`isMobile: ontouchstart=${'ontouchstart' in document.documentElement} mobi=${/mobi/i.test(navigator.userAgent)}`)
let _isMobile = ('ontouchstart' in document.documentElement && /mobi/i.test(navigator.userAgent) )
return _isMobile
}
export function camelToKebab(input:string) { return input.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}
export function kebabToCamel(input:string) { return input.replace(/-([a-z])/g, function (g) {return g[1].toUpperCase()})}
export function isTouchEnabled() {
return ( 'ontouchstart' in window ) ||
( navigator.maxTouchPoints > 0 ) ||
( ((navigator as any).msMaxTouchPoints || 0) > 0 )
}
let tippyEntities:any
export function initTippy(el:any=null, force=false) {
let validAttrs = new Set('qid label description image'.split(/\s/))
if (force) tippyEntities = null
el = el || document
let _entities = Array.from(el.querySelectorAll('mark')).filter(el =>
Array.from((el as HTMLElement).attributes).find(attr => {
let attrName = attr.name.toLowerCase()
return validAttrs.has(attrName) || (isQID(attr.value) && attrName !== 'flyto')
})
)
if (!tippyEntities && _entities.length > 0) {
tippyEntities = _entities
// console.log(`initTippy: entities=${tippyEntities.length}`)
// If we have a data-popover-trigger attribute, group by that value,
// otherwise default to 'mouseenter focus'
const grouped = tippyEntities.reduce((acc: any, el: any) => {
const key = el.getAttribute('data-popover-trigger') || 'mouseenter focus'
if (!acc[key]) acc[key] = []
acc[key].push(el)
return acc
}, {})
// Initialize tippy for each group, with the appropriate trigger
for (const key in grouped) {
const config = {
interactive: true,
allowHTML: true,
trigger: key,
delay: [null, null],
onShow: (instance:any) => {
let attrs = Array.from(instance.reference.attributes)
.filter((attr:any) => validAttrs.has(attr.name))
.map((attr:any) => `${attr.name}="${attr.value}"`)
instance.setContent(``)
}
}
tippy(grouped[key], config)
}
}
}
export async function getManifest(manifestId: string, refresh: boolean=false) {
let manifestUrl = manifestId.indexOf('http') === 0
? manifestId
: `${iiifServer}/${manifestId}/manifest.json`
let manifests = await loadManifests([manifestUrl], refresh)
return manifests[0]
}
export async function prezi2to3(manifest: any) {
/* Converts IIIF v2 manifest to v3 */
let resp = await fetch('https://iiif.juncture-digital.org/prezi2to3/', {
method: 'POST',
body: JSON.stringify(manifest)
})
if (resp.ok) return (await resp).json()
}
function observeNavbar(navbar:HTMLElement, target:HTMLElement) {
const setTop = () => {
let top = parseInt(navbar.style.top.replace(/^-/,'').replace(/px$/,'')) || 0
let height = parseInt(navbar.style.height.replace(/px$/,'')) || navbar.clientHeight
let topOffset = height - top
target.style.top = `${topOffset}px`
}
setTop()
const observer = new MutationObserver(setTop)
observer.observe(navbar, { attributes: true })
}
export function makeSticky(el:HTMLElement) {
el.classList.add('sticky')
el.style.position = 'sticky'
// el.style.marginTop = '6px'
let stickyNavbar:any = document.querySelector('ve-navbar[sticky="true"]') as HTMLElement
if (stickyNavbar) {
observeNavbar(stickyNavbar, el)
} else {
let header = (document.querySelector('ve-header[sticky]') as HTMLElement)
if (header) {
stickyNavbar = header?.shadowRoot?.querySelector('ve-navbar')
if (stickyNavbar) {
observeNavbar(stickyNavbar, el)
} else {
const observer = new MutationObserver(() => {
stickyNavbar = header?.shadowRoot?.querySelector('ve-navbar')
if (stickyNavbar) observeNavbar(stickyNavbar, el)
})
observer.observe(header, { childList: true, subtree: true, attributes: true })
}
} else {
el.style.top = '0'
}
}
}
export function findItem(toMatch: object, current: object, seq: number = 1): any {
const found = _findItems(toMatch, current)
return found.length >= seq ? found[seq-1] : null
}
function _findItems(toMatch: object, current: any, found: object[] = []) {
found = found || []
if (current.items) {
for (let i = 0; i < current.items.length; i++ ) {
let item = current.items[i]
let isMatch = !Object.entries(toMatch).find(([attr, val]) => item[attr] && item[attr] !== val)
if (isMatch) found.push(item)
else _findItems(toMatch, item, found)
}
}
return found
}
export function getItemInfo(manifest:any, seq=1) {
// console.log(`itemInfo: seq=${seq}`, manifest)
let _itemInfo = findItem({type:'Annotation', motivation:'painting'}, manifest, seq).body
// if (_itemInfo.service) _itemInfo.service = _itemInfo.service.map((svc:any) => ({...svc, ...{id: (svc.id || svc['@id']).replace(/\/info\.json$/,'')}}))
return _itemInfo
}
export function imageCount(manifest:any) {
return _findItems({type:'Annotation', motivation:'painting'}, manifest).length
}
const _manifestCache:any = {}
export async function loadManifests(manifestUrls: string[], refresh: boolean=false) {
let _manifestUrls = manifestUrls
.map(manifestId =>
manifestId.indexOf('http') === 0
? manifestId
: `${iiifServer}/${manifestId}/manifest.json`
)
let toGet = _manifestUrls.filter(url => !_manifestCache[url])
// console.log('loadManifests', toGet)
if (toGet.length > 0) {
let requests: any = toGet
.map(manifestUrl => {
if (refresh && ['localhost', 'iiif.juncture-digital.org'].includes(new URL(manifestUrl).hostname)) {
manifestUrl += '?refresh'
}
return fetch(manifestUrl)
})
let responses = await Promise.all(requests)
let manifests = await Promise.all(responses.map((resp:any) => resp.json()))
requests = manifests
.filter(manifest => !Array.isArray(manifest['@context']) && parseFloat(manifest['@context'].split('/').slice(-2,-1).pop()) < 3)
.map(manifest => fetch('https://iiif.juncture-digital.org/prezi2to3/', {
method: 'POST',
body: JSON.stringify(manifest)
}))
if (requests.length > 0) {
responses = await Promise.all(requests)
let convertedManifests = await Promise.all(responses.map((resp:any) => resp.json()))
for (let i = 0; i < manifests.length; i++) {
let mid = manifests[i].id ||manifests[i]['@id']
let found = convertedManifests.find(manifest => (manifest.id || manifest['@id']) === mid)
if (found) manifests[i] = found
}
}
manifests.forEach((manifest, idx) => _manifestCache[toGet[idx]] = manifest)
return _manifestUrls.map(url => _manifestCache[url])
} else {
return _manifestUrls.map(url => _manifestCache[url])
}
}
function _value(langObj: any, language='en') {
return typeof langObj === 'object'
? langObj[language] || langObj.none || langObj[Object.keys(langObj).sort()[0]]
: langObj
}
export function label(manifest:any, language:string = 'en') {
return manifest ? _value(manifest.label, language) : null
}
export function summary(manifest:any, language:string = 'en') {
return manifest ? _value(manifest.summary, language) : null
}
export function staticImage(manifest: any, options:any, width:number=0, height:number=0) {
// console.log('staticImage', manifest, options)
let _imageInfo = getItemInfo(manifest)
let region = options.region || 'full'
let size = options.size
? options.size
: width
? height
? `${width},${height}`
: `${width},`
: height
? `,${height}`
: '400,'
let rotation = options.rotation || '0'
let quality = options.quality || 'default'
let format = options.format || 'jpg'
let url =`${_imageInfo.service[0].id || _imageInfo.service[0]['@id']}/${region}/${size}/${rotation}/${quality}.${format}`
// console.log(url)
return url
}
export function thumbnail(manifest:any, width:number=400, seq:number=1) {
if (!manifest) return null
let _imageInfo = getItemInfo(manifest, seq)
return _imageInfo.service
? `${_imageInfo.service[0].id || _imageInfo.service[0]['@id']}/full/${width},/0/default.jpg`
: _imageInfo.type === 'Video'
? `https://iiif.juncture-digital.org/thumbnail?url=${_imageInfo.id}`
: _imageInfo.id
}
export function parseRegionString(region: string, viewer: OpenSeadragon.Viewer) {
let viewportRect
const s1 = region.split(':')
let [x,y,w,h] = s1[s1.length-1].split(',').map(v => parseInt(v))
const size = viewer.world.getItemAt(0).getContentSize()
if (s1.length === 2 && (s1[0] === 'pct' || s1[0] === 'percent')) {
x = Math.round(size.x * x/100),
y = Math.round(size.y * y/100),
w = Math.round(size.x * w/100),
h = Math.round(size.y * h/100)
}
// viewportRect = viewer.viewport.imageToViewportRectangle(rect)
viewportRect = viewer.viewport.imageToViewportRectangle(x,y,w,h)
return viewportRect
}
export function getMetadata(manifest:any, language:string = 'en'): any[] {
let metadata:any[] = []
if (manifest.metadata) {
manifest.metadata.forEach((md:any) => {
metadata.push({label: _value(md.label, language)[0], value: _value(md.value, language)})
})
}
return metadata
}
export function metadataAsObj(manifest:any, language:string = 'en'): any {
return Object.fromEntries(getMetadata(manifest, language).map(md => [md.label, md.value]))
}
export function top() {
return stickyElems.length > 0 && stickyElems[0].localName.toLowerCase() === 've-navbar'
? parseInt(window.getComputedStyle(stickyElems[0]).height.slice(0,-2))
: 0
}
function findStickyElems() {
initTippy()
let stickyElems = Array.from(document.querySelectorAll('.sticky'))
// .filter(el => el.localName.toLowerCase() !== 've-content-selector')
let stickyNavBarIdx = stickyElems.findIndex(el => el.localName.toLowerCase() === 've-navbar')
if (stickyNavBarIdx < 0) {
let headerIdx = stickyElems.findIndex(el => el.localName.toLowerCase() === 've-header')
if (headerIdx >= 0) {
let stickyNavBar = stickyElems[headerIdx]?.shadowRoot?.querySelector('ve-navbar.sticky')
if (stickyNavBar) {
stickyElems[headerIdx] = stickyNavBar
stickyNavBarIdx = headerIdx
}
}
}
// let main = document.querySelector('main')
// if (main) main.style.paddingBottom = '75vh'
stickyElems.forEach((el:any) => setTop(el))
// console.log('stickyElems', stickyElems)
return stickyElems
}
function activeRegionOffset() {
let stickyNavBar = stickyElems.find((el:HTMLElement) => el.localName.toLowerCase() === 've-navbar')
let offset = stickyNavBar ? stickyNavBar.getBoundingClientRect().top : 0
stickyElems.forEach(el => {
let bcr = el.getBoundingClientRect()
let col = bcr.x < bcr.width ? 0 : 1
if (col === 0 && bcr.top === offset) {
let computedHeightStyle = window.getComputedStyle(el).height
if (computedHeightStyle.length >= 3 && computedHeightStyle.slice(-2) === 'px') {
offset += parseInt(window.getComputedStyle(el).height.slice(0,-2))
}
}
})
// console.log(`activeRegionOffset=${offset}`)
return offset
}
function setTop(el:HTMLElement) {
if (el.localName.toLowerCase() === 'section') {
let stickyNavBar = stickyElems.find(el => el.localName.toLowerCase() === 've-navbar')
// let offset = stickyNavBar ? stickyNavBar.getBoundingClientRect().top : 0
let offset = stickyNavBar ? stickyNavBar.clientHeight : 0
el.style.top = `${offset}px`
}
}
let stickyElems:any[] = []
const mutationObserver = new MutationObserver((mutations) => {
let nonTippyMutations = mutations.filter(mut => {
let addedCt = Array.from(mut.addedNodes).filter((node:any) => node.id?.indexOf('tippy') < 0).length
let removedCt = Array.from(mut.removedNodes).filter((node:any) => node.id?.indexOf('tippy') < 0).length
return (addedCt + removedCt) > 0
})
if (!tippyEntities || nonTippyMutations.length > 0) stickyElems = findStickyElems()
})
mutationObserver.observe(document, { childList: true, subtree: true })
let targets:any = {}
let active: HTMLElement
let offset = 0
const intersectionObserver = new IntersectionObserver (
(entries) => {
offset = activeRegionOffset()
entries.forEach(entry => targets[domPath(entry.target)] = entry)
let intersecting = Object.values(targets)
.filter((entry:any) => entry.isIntersecting)
.filter((entry:any) => entry.target.getBoundingClientRect().y >= offset)
.sort((a:any,b:any) => a.target.getBoundingClientRect().y > b.target.getBoundingClientRect().y ? 1 : -1)
// console.log(intersecting.map((entry:any) => ({ el:entry.target, ratio:entry.intersectionRatio })))
let selected:any = (intersecting.find((entry:any) => entry.intersectionRatio === 1) || intersecting[0])
if (selected) {
let selectedEl = selected.target as HTMLElement
if (Array.from(selectedEl.classList).indexOf('active') < 0) {
if (active) active.classList.remove('active')
selectedEl.classList.add('active')
active = selectedEl
}
}
},
{ root: null, threshold: [0, 0.25, 0.5, 0.75, 1], rootMargin: `-${offset}px 0px 0px 0px` }
)
Array.from(document.querySelectorAll('p')).forEach(el => intersectionObserver.observe(el))
export function observeActive(el:HTMLElement) {
Array.from(el.querySelectorAll('p')).forEach(p => intersectionObserver.observe(p))
}
export function domPath(el: any) {
var stack = []
while ( el.parentNode != null ) {
let sibCount = 0
let sibIndex = 0
for ( var i = 0; i < el.parentNode.childNodes.length; i++ ) {
let sib = el.parentNode.childNodes[i];
if ( sib.nodeName == el.nodeName ) {
if ( sib === el ) {
sibIndex = sibCount;
}
sibCount++
}
}
if ( el.hasAttribute('id') && el.id != '' ) {
stack.unshift(el.nodeName.toLowerCase() + `#${el.id}`)
} else if ( sibCount > 1 ) {
stack.unshift(el.nodeName.toLowerCase() + (sibIndex > 0 ? `[${sibIndex}]` : ''))
} else {
stack.unshift(el.nodeName.toLowerCase())
}
el = el.parentNode as HTMLElement
}
return stack.join('.')
}
let entityData:any = {}
export async function getEntityData(qids: string[] = [], language: string = 'en') {
let values = qids.filter(qid => !entityData[qid]).map(qid => `()`)
// console.log(`getEntityData: qids=${qids.length} toGet=${values.length}`)
if (values.length > 0) {
let query = `
SELECT ?item ?label ?description ?alias ?image ?logoImage ?coords ?pageBanner ?whosOnFirst ?wikipedia WHERE {
VALUES (?item) { ${values.join(' ')} }
?item rdfs:label ?label .
FILTER (LANG(?label) = "${language}" || LANG(?label) = "en")
OPTIONAL { ?item schema:description ?description . FILTER (LANG(?description) = "${language}" || LANG(?description) = "en")}
OPTIONAL { ?item skos:altLabel ?alias . FILTER (LANG(?alias) = "${language}" || LANG(?alias) = "en")}
OPTIONAL { ?item wdt:P625 ?coords . }
OPTIONAL { ?item wdt:P18 ?image . }
OPTIONAL { ?item wdt:P154 ?logoImage . }
OPTIONAL { ?item wdt:P948 ?pageBanner . }
OPTIONAL { ?item wdt:P6766 ?whosOnFirst . }
OPTIONAL { ?wikipedia schema:about ?item; schema:isPartOf . }
}`
let resp = await fetch('https://query.wikidata.org/sparql', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/sparql-results+json'
},
body: `query=${encodeURIComponent(query)}`
})
if (resp.ok) {
let sparqlResp = await resp.json()
sparqlResp.results.bindings.forEach((rec: any) => {
let qid = rec.item.value.split('/').pop()
if (!entityData[qid]) {
entityData[qid] = {id: qid, label: rec.label.value}
if (rec.description) entityData[qid].description = rec.description.value
if (rec.alias) entityData[qid].aliases = [rec.alias.value]
if (rec.coords) entityData[qid].coords = rec.coords.value.slice(6,-1).split(' ').reverse().join(',')
if (rec.wikipedia) entityData[qid].wikipedia = rec.wikipedia.value
if (rec.pageBanner) entityData[qid].pageBanner = rec.pageBanner.value
if (rec.image) {
entityData[qid].image = rec.image.value
entityData[qid].thumbnail = mwImage(rec.image.value, 300)
}
if (rec.logoImage) {
entityData[qid].logoImage = rec.logoImage.value
if (!entityData[qid].thumbnail) entityData[qid].thumbnail = mwImage(rec.logoImage.value, 300)
}
if (rec.whosOnFirst) entityData[qid].geojson = whosOnFirstUrl(rec.whosOnFirst.value)
} else {
if (rec.alias) entityData[qid].aliases.push(rec.alias.value)
}
})
// return entityData
return Object.fromEntries(qids.filter(qid => entityData[qid]).map(qid => [qid,entityData[qid]]))
}
}
// return entityData
return Object.fromEntries(qids.filter(qid => entityData[qid]).map(qid => [qid,entityData[qid]]))
}
export function isQID(s: string) {
return s[0] === 'Q' && isNum(s.slice(1))
}
export async function getEntity(qid: string, language: string = 'en') {
let entities = await getEntityData([qid], language)
return entities[qid]
}
export function mwImage(mwImg:string, width:number) {
// Converts Wikimedia commons image URL to a thumbnail link
mwImg = (Array.isArray(mwImg) ? mwImg[0] : mwImg).replace(/Special:FilePath\//, 'File:').split('File:').pop()
mwImg = decodeURIComponent(mwImg).replace(/ /g,'_')
const _md5 = md5(mwImg)
const extension = mwImg.split('.').pop()
let url = `https://upload.wikimedia.org/wikipedia/commons${width ? '/thumb' : ''}`
url += `/${_md5.slice(0,1)}/${_md5.slice(0,2)}/${mwImg}`
if (width) {
url += `/${width}px-${mwImg}`
if (extension === 'svg') {
url += '.png'
} else if (extension === 'tif' || extension === 'tiff') {
url += '.jpg'
}
}
return url
}
// Creates a GeoJSON file URL from a Who's on First ID
function whosOnFirstUrl(wof:string) {
let wofParts = []
for (let i = 0; i < wof.length; i += 3) {
wofParts.push(wof.slice(i,i+3))
}
return `https://data.whosonfirst.org/${wofParts.join('/')}/${wof}.geojson`
}
export function isNum(s:string) {
return s && !isNaN(s)
}
export function parseImageOptions(str: string) {
let elems = str?.split('/') || []
// let seq = 1
let region = 'full'
let size = 'full'
let rotation = '0'
let quality = 'default'
let format = 'jpg'
let offset = 0
/*
if (isNum(elems[0])) {
seq = +elems[0]
offset = 1
}
*/
let options = {
// seq,
region: elems.length > offset && elems[offset] ? elems[offset] : region,
size: elems.length > offset+1 && elems[offset+1] ? elems[offset+1] : size,
rotation: elems.length > offset+2 && elems[offset+2] ? elems[offset+2] : rotation,
quality: elems.length > offset+3 && elems[offset+3] ? elems[offset+3] : quality,
format: elems.length > offset+4 && elems[offset+4] ? elems[offset+4] : format
}
return options
}
// For cropping regular images
export async function imageDataUrl(url: string, region: any, dest: any): Promise {
return new Promise((resolve) => {
let {x, y, w, h} = region
let {width, height} = dest
let image = new Image()
image.crossOrigin = 'anonymous'
x = x ? x/100 : 0
y = y ? y/100 : 0
w = w ? w/100 : 0
h = h ? h/100 : 0
image.onload = () => {
let sw = image.width
let sh = image.height
let swScaled = w > 0 ? sw * w : sw - (sw * x)
let shScaled = h > 0 ? sh * h : sh - (sh * y)
let ratio = swScaled/shScaled
if (ratio > 1) height = width/ratio
else width = height * ratio
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = width
canvas.height = height
x = x*sw
y = y*sh
// console.log(`x=${x} y=${y} sw=${sw} sh=${sh} swScaled=${swScaled} shScaled=${shScaled} width=${width} height=${height} ratio=${ratio}`)
ctx?.drawImage(image, x, y, swScaled, shScaled, 0, 0, width, height)
let dataUrl = canvas.toDataURL()
resolve(dataUrl)
}
image.src = url
})
}
let summaryText:any = {}
export async function getSummaryText(qid: string, language='en') {
// console.log(`getSummaryText: qid=${qid} language=${language}`)
if (!summaryText[language]) summaryText[language] = {}
if (!summaryText[language][qid] && entityData[qid] && entityData[qid].wikipedia) {
let wikiUrl = entityData[qid].wikipedia
let page: number = wikiUrl.replace(/\/w\//, '/wiki').split('/wiki/').pop()
let url = `https://${language}.wikipedia.org/api/rest_v1/page/summary/${page}`
let resp: any = await fetch(url)
if (resp.ok) {
resp = await resp.json()
summaryText[language][qid] = resp['extract_html'] || resp['extract']
}
return summaryText[language][qid]
} else {
return summaryText[language][qid]
}
}