Reconstructive/reconstructive-banner.js
/**
* [ReconstructiveBanner](https://oduwsdl.github.io/Reconstructive/reconstructive-banner.js) implements `<reconstructive-banner>` [Custom Element](https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements).
* It is an unobtrusive archival replay banner to make [mementos](http://mementoweb.org/about/) interactive and surface on-demand metadata about the archived resource.
* The banner houses a customizable branding logo that links to the replay home.
* It provides a pre-populated text input to navigate the replay to a different URI-R.
* A brief phrase describes the rough age of the current memento, but can be toggled with the absolute date and time of capture by repeatedly clicking on it.
* User's preference on absolute or relative datetime display persists across the session.
* Navigational links to the first, last, previous, and next mementos are also provided when present.
* In its default floating action bar (FAB) mode it auto-hides after a set duration of inactivity if the banner is not in focus and reappears on any user activity on the page such as scroll, mousemove, or keypress.
* The FAB can be dragged to repostion it on the page, which then persists across the session unless relocated again.
* It provides controls to expand, collapse, or completely close the banner.
* The expanded mode has much more real estate available to house detailed archival metadata and visualizations.
* Use it in an HTML page as illustrated below:
*
* ```html
* <script src="reconstructive-banner.js"></script>
* <reconstructive-banner logo-src=""
* home-href="/"
* urir="https://example.com/"
* memento-datetime="Mon, 06 Feb 2017 00:23:37 GMT"
* first-urim="https://archive.host/memento/20170206002337/https://example.com/"
* first-datetime="Mon, 06 Feb 2017 00:23:37 GMT"
* last-urim="https://archive.host/memento/20170206002337/https://example.com/"
* last-datetime="Mon, 06 Feb 2017 00:23:37 GMT"
* prev-urim=""
* prev-datetime=""
* next-urim=""
* next-datetime="">
* </reconstructive-banner>
* ```
*
* @overview ReconstructiveBanner implements <reconstructive-banner> Custom Element for archival replay banners.
* @author Sawood Alam <ibnesayeed@gmail.com>
* @license MIT
* @copyright ODU Web Science / Digital Libraries Research Group 2017
*/
class ReconstructiveBanner extends HTMLElement {
/**
* Create a new ReconstructiveBanner instance and attach a Shadow DOM.
*/
constructor() {
super();
/**
* ShadoRoot for the isolated Shadow DOM of the banner.
*
* @type {ShadowRoot}
*/
this.shadow = this.attachShadow({ mode: 'closed' });
}
/**
* Read various attributes of the element and initialize the rendition and behavious of the banner when this custom element is added to the DOM.
*/
connectedCallback() {
/**
* A base64-encoded data URI of the SVG Reconstructive Logo.
* Used as the default banner logo if a custom logoSrc is not specified.
*
* @type {string}
*/
this.LOGO = 'data:image/svg+xml;charset=utf-8;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZD0iTTAgMyBhMyAzIDAgMCAxIDMtMyBoMiBsMyAzIGgtMyBhMiAyIDAgMCAwLTIgMiB2NiBhMiAyIDAgMCAwIDIgMiBoMSBsMyAzIGgtNiBhMyAzIDAgMCAxLTMtMyBaIiBmaWxsPSIjMUI0ODY5IiAvPjxwYXRoIGQ9Ik0xNiAxNiBoLTQgbC05LTkgaDYgYTIgMiAwIDAgMCAwLTQgaC0xIGwtMy0zIGg2IGEzIDMgMCAwIDEgMyAzIHY0IGEzIDMgMCAwIDEtMyAzIGgtMSBaIiBmaWxsPSIjRjI0NzM4IiAvPjwvc3ZnPg==';
/**
* Source (URL or path) of the banner logo.
* Defaults to the inline Reconstructive Logo.
*
* @type {string}
*/
this.logoSrc = this.getAttribute('logo-src') || this.LOGO;
/**
* Hyperlink (URL or path) of the homepage to be linked from the banner logo.
* Read from the home-href attribute.
* Defaults to the domain root "/".
*
* @type {string}
*/
this.homeHref = this.getAttribute('home-href') || '/';
/**
* Original resource URI (URI-R).
* Read from the urir attribute.
*
* @type {string}
*/
this.urir = this.getAttribute('urir') || '';
/**
* Datetime (in the RFC2822 format) when the current memento was captured.
* Read from the memento-datetime attribute.
*
* @type {string}
*/
this.mementoDatetime = this.getAttribute('memento-datetime') || '';
/**
* URI of the first memento.
* Read from the first-urim attribute.
*
* @type {string}
*/
this.firstUrim = this.getAttribute('first-urim') || '';
/**
* Datetime (in the RFC2822 format) when the first memento was captured.
* Read from the first-datetime attribute.
*
* @type {string}
*/
this.firstDatetime = this.getAttribute('first-datetime') || '';
/**
* URI of the last memento.
* Read from the last-urim attribute.
*
* @type {string}
*/
this.lastUrim = this.getAttribute('last-urim') || '';
/**
* Datetime (in the RFC2822 format) when the last memento was captured.
* Read from the last-datetime attribute.
*
* @type {string}
*/
this.lastDatetime = this.getAttribute('last-datetime') || '';
/**
* URI of the previous memento.
* Read from the prev-urim attribute.
*
* @type {string}
*/
this.prevUrim = this.getAttribute('prev-urim') || '';
/**
* Datetime (in the RFC2822 format) when the previous memento was captured.
* Read from the prev-datetime attribute.
*
* @type {string}
*/
this.prevDatetime = this.getAttribute('prev-datetime') || '';
/**
* URI of the next memento.
* Read from the next-urim attribute.
*
* @type {string}
*/
this.nextUrim = this.getAttribute('next-urim') || '';
/**
* Datetime (in the RFC2822 format) when the next memento was captured.
* Read from the next-datetime attribute.
*
* @type {string}
*/
this.nextDatetime = this.getAttribute('next-datetime') || '';
/**
* Duration of inactivity after which the banner in FAB mode should auto-hide if not in focus.
* The default value is set to 2000 milliseconds (2 seconds).
*
* @type {number}
*/
this.autoHideDelay = 2000;
/**
* A function to provide human readable dispaly datetime strings for the current memento in both relative and absolute terms.
* Relative datetime is a non-precise natural language phrase (e.g., "Captured one day and 3 hours ago").
* Absolute datetime is a precise natural language phrase in user's locace (e.g., "Captured on 8/13/2018 at 7:23:37 PM").
*
* @type {function(): object}
*/
this.displayDatetime = (() => {
let datetime = { relative: this.mementoDatetime, absolute: this.mementoDatetime };
const mementoDatetimeObj = new Date(this.mementoDatetime);
const diff = Date.now() - mementoDatetimeObj;
if (isNaN(diff)) {
return datetime;
}
let [mementoDate, mementoTime] = mementoDatetimeObj.toISOString().split(/[T\.]/);
datetime.absolute = `Captured on ${mementoDate} at ${mementoTime} UTC`;
if (diff < 0) {
datetime.relative = 'Capture from the future!';
return datetime;
}
const datetimeUnits = ['year', 'month', 'day', 'hour', 'minute', 'second', 'millisecond'];
let datetimeParts = new Date(diff).toISOString().split(/\D/).map(x => parseInt(x));
datetimeParts[0] -= 1970;
datetimeParts[1] -= 1;
datetimeParts[2] -= 1;
let primaryUnit, secondaryUnit, primaryQuotient, secondaryQuotient;
for (let i = 0; i < datetimeUnits.length - 1; i++) {
if (datetimeParts[i] == 0) {
continue;
}
[primaryUnit, secondaryUnit] = datetimeUnits.slice(i, i + 2);
[primaryQuotient, secondaryQuotient] = datetimeParts.slice(i, i + 2);
break;
}
let diffStr = `${primaryQuotient} ${primaryUnit}${primaryQuotient != 1 ? 's' : ''}`;
if (secondaryQuotient > 0) {
diffStr += ` and ${secondaryQuotient} ${secondaryUnit}${secondaryQuotient != 1 ? 's' : ''}`;
}
datetime.relative = `Captured ${diffStr} ago`;
return datetime;
})();
const template = `
<style>
a[href=''] {
pointer-events: none;
opacity: 0.4;
}
#wrapper {
z-index: 99999999;
padding: 10px;
box-sizing: border-box;
}
#wrapper.fab {
position: fixed;
top: calc(100vh - 100px);
left: 20px;
}
#wrapper.expanded {
position: fixed;
top: 0;
left: 0;
margin: 0;
width: 100%;
height: 100%;
background: rgba(100, 100, 100, 0.6);
}
#wrapper.hidden {
opacity: 0;
transition: opacity 0.5s ease-in;
}
#wrapper.closed {
display: none;
}
#container {
border: 2px solid #451212;
background-color: #F2FFE3;
border-radius: 10px;
color: #1B4869;
max-width: 600px;
margin: auto;
padding: 5px;
box-shadow: 0 0 20px;
display: grid;
grid-template-columns: 10px fit-content(300px) 20px 20px 1fr 20px 20px 20px;
grid-template-rows: 20px 20px 1fr;
grid-gap: 2px 10px;
box-sizing: border-box;
min-height: 50px;
}
.expanded #container {
height: calc(100vh - 20px);
box-shadow: none;
}
.fab #collapse, .fab #meta, .expanded #expand {
display: none;
}
form {
display: contents;
}
input {
padding: 0 5px;
box-sizing: border-box;
}
.branding {
height: 42px;
max-width: 300px;
}
.icon {
width: 20px;
}
#drag {
grid-column: 1;
grid-row: 1 / 3;
border: #1B4869 3px dotted;
cursor: move;
}
.expanded #drag {
opacity: 0.4;
cursor: default;
}
#logo {
grid-column: 2;
grid-row: 1 / 3;
}
#urir {
grid-column: 3 / 8;
grid-row: 1;
}
#first {
grid-column: 3;
grid-row: 2;
}
#prev {
grid-column: 4;
grid-row: 2;
}
#current {
grid-column: 5;
grid-row: 2;
margin: 0 5px;
font-size: 16px;
line-height: 22px;
color: #323B40;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
user-select: none;
}
.relative {
cursor: help;
}
.precision .relative, .absolute {
display: none;
}
.precision .absolute {
display: initial;
}
#next {
grid-column: 6;
grid-row: 2;
}
#last {
grid-column: 7;
grid-row: 2;
}
#close {
grid-column: 8;
grid-row: 1;
}
#expand {
grid-column: 8;
grid-row: 2;
}
#collapse {
grid-column: 8;
grid-row: 2;
}
#meta {
grid-column: 1 / 9;
grid-row: 3;
overflow: auto;
padding: 10px;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: auto auto auto 130px 1fr;
}
</style>
<div id="wrapper" class="fab" style="${localStorage.getItem('bannerPosition') || ''}">
<div id="container">
<div id="drag"></div>
<a id="home" title="Go to home" href="${this.homeHref}" rel="noreferrer">
<img id="logo" class="branding" src="${this.logoSrc}" alt="Banner Logo">
</a>
<form id="lookup">
<input id="urir" class="url" value="${this.urir}">
<form>
<a id="first" class="icon" title="${this.firstDatetime}" href="${this.firstUrim}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M4 28v-24h4v11l10-10v10l10-10v22l-10-10v10l-10-10v11z"></path></svg>
</a>
<a id="prev" class="icon" title="${this.prevDatetime}" href="${this.prevUrim}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M8 28v-24h4v11l10-10v22l-10-10v11z"></path></svg>
</a>
<p id="current" class="datetime ${localStorage.getItem('datetimeDisplay') || ''}" title="${this.mementoDatetime}">
<span class="relative">${this.displayDatetime.relative}</span>
<span class="absolute">${this.displayDatetime.absolute}</span>
</p>
<a id="next" class="icon" title="${this.nextDatetime}" href="${this.nextUrim}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M24 4v24h-4v-11l-10 10v-22l10 10v-11z"></path></svg>
</a>
<a id="last" class="icon" title="${this.lastDatetime}" href="${this.lastUrim}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M28 4v24h-4v-11l-10 10v-10l-10 10v-22l10 10v-10l10 10v-11z"></path></svg>
</a>
<a id="close" class="icon" href="#">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M31.708 25.708c-0-0-0-0-0-0l-9.708-9.708 9.708-9.708c0-0 0-0 0-0 0.105-0.105 0.18-0.227 0.229-0.357 0.133-0.356 0.057-0.771-0.229-1.057l-4.586-4.586c-0.286-0.286-0.702-0.361-1.057-0.229-0.13 0.048-0.252 0.124-0.357 0.228 0 0-0 0-0 0l-9.708 9.708-9.708-9.708c-0-0-0-0-0-0-0.105-0.104-0.227-0.18-0.357-0.228-0.356-0.133-0.771-0.057-1.057 0.229l-4.586 4.586c-0.286 0.286-0.361 0.702-0.229 1.057 0.049 0.13 0.124 0.252 0.229 0.357 0 0 0 0 0 0l9.708 9.708-9.708 9.708c-0 0-0 0-0 0-0.104 0.105-0.18 0.227-0.229 0.357-0.133 0.355-0.057 0.771 0.229 1.057l4.586 4.586c0.286 0.286 0.702 0.361 1.057 0.229 0.13-0.049 0.252-0.124 0.357-0.229 0-0 0-0 0-0l9.708-9.708 9.708 9.708c0 0 0 0 0 0 0.105 0.105 0.227 0.18 0.357 0.229 0.356 0.133 0.771 0.057 1.057-0.229l4.586-4.586c0.286-0.286 0.362-0.702 0.229-1.057-0.049-0.13-0.124-0.252-0.229-0.357z"></path></svg>
</a>
<a id="expand" class="icon" href="#">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M32 0h-13l5 5-6 6 3 3 6-6 5 5z"></path><path d="M32 32v-13l-5 5-6-6-3 3 6 6-5 5z"></path><path d="M0 32h13l-5-5 6-6-3-3-6 6-5-5z"></path><path d="M0 0v13l5-5 6 6 3-3-6-6 5-5z"></path></svg>
</a>
<a id="collapse" class="icon" href="#">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M18 14h13l-5-5 6-6-3-3-6 6-5-5z"></path><path d="M18 18v13l5-5 6 6 3-3-6-6 5-5z"></path><path d="M14 18h-13l5 5-6 6 3 3 6-6 5 5z"></path><path d="M14 14v-13l-5 5-6-6-3 3 6 6-5 5z"></path></svg>
</a>
<div id="meta">
<!-- TODO: Add provenance infomration, metadata, and interactive visualizations here... -->
</div>
</div>
</div>
`;
this.shadow.innerHTML = template;
// Banner container and its outer wrapper element references
const container = this.shadow.getElementById('container');
const wrapper = this.shadow.getElementById('wrapper');
// Banner auto-hide and reveal logic
let bannerFocused = false;
container.onmouseover = () => bannerFocused = true;
container.onmouseout = () => bannerFocused = false;
let focusTimer;
const resetTimer = () => {
wrapper.classList.remove('hidden');
clearTimeout(focusTimer);
focusTimer = setTimeout(() => !bannerFocused && wrapper.classList.contains('fab') && wrapper.classList.add('hidden'), this.autoHideDelay);
};
window.addEventListener('load', resetTimer);
window.addEventListener('mousemove', resetTimer);
window.addEventListener('mousedown', resetTimer);
window.addEventListener('click', resetTimer);
window.addEventListener('scroll', resetTimer);
window.addEventListener('keypress', resetTimer);
// Banner expand, collapse, and close logic
this.shadow.getElementById('close').onclick = e => {
e.preventDefault();
wrapper.classList.remove('fab', 'expanded');
wrapper.classList.add('closed');
};
this.shadow.getElementById('expand').onclick = e => {
e.preventDefault();
wrapper.classList.replace('fab', 'expanded');
wrapper.style.removeProperty('top');
wrapper.style.removeProperty('left');
};
this.shadow.getElementById('collapse').onclick = e => {
e.preventDefault();
wrapper.classList.replace('expanded', 'fab');
wrapper.style.cssText = localStorage.getItem('bannerPosition') || '';
};
wrapper.onclick = e => {
if (e.target == wrapper) {
wrapper.classList.replace('expanded', 'fab');
wrapper.style.cssText = localStorage.getItem('bannerPosition') || '';
}
};
// Current memento's datetime display logic (absolute or relative)
const datetimeDisplay = this.shadow.getElementById('current');
datetimeDisplay.onclick = e => {
if (datetimeDisplay.classList.toggle('precision')) {
localStorage.setItem('datetimeDisplay', 'precision');
} else {
localStorage.removeItem('datetimeDisplay');
}
};
// Banner drag repositioning logic
let draggable = false;
let offset = { x: 0, y: 0 };
this.shadow.getElementById('drag').onmousedown = e => {
if (wrapper.classList.contains('fab')) {
draggable = true;
offset = {
x: wrapper.offsetLeft - e.clientX,
y: wrapper.offsetTop - e.clientY
};
}
};
window.addEventListener('mouseup', e => {
if (draggable && wrapper.classList.contains('fab')) {
draggable = false;
localStorage.setItem('bannerPosition', `left: ${wrapper.offsetLeft}px; top: ${wrapper.offsetTop}px;`);
}
});
window.addEventListener('mousemove', e => {
e.preventDefault();
if (draggable && wrapper.classList.contains('fab')) {
wrapper.style.left = Math.max(Math.min(e.clientX + offset.x, window.innerWidth - 45), 10) + 'px';
wrapper.style.top = Math.max(Math.min(e.clientY + offset.y, window.innerHeight - 85), 10) + 'px';
}
});
// URI-R lookup submission logic
this.shadow.getElementById('lookup').onsubmit = e => {
e.preventDefault();
const urir = this.shadow.getElementById('urir').value;
if (urir) {
window.location = window.location.href.replace(this.urir, urir);
}
};
}
}
customElements.define('reconstructive-banner', ReconstructiveBanner);