hlc-server
Version:
Serves real-time real-world context at a human scale by combining RFID, RTLS and M2M with structured, linked data on the web. We believe in an open Internet of Things.
713 lines (590 loc) • 23.1 kB
JavaScript
/**
* Copyright reelyActive 2016-2020
* We believe in an open Internet of Things
*/
let cuttlefish = (function() {
// Internal constants
const IMG_CLASS = 'card-img-top';
const HEADER_CLASS = 'card-header';
const BODY_CLASS = 'card-body';
const TAB_BODY_CLASS = 'card-body bg-white';
const FOOTER_CLASS = 'card-footer lead';
const TITLE_CLASS = 'card-title text-truncate';
const SUBTITLE_CLASS = 'card-subtitle text-muted text-truncate';
const STORIES_PANE_SUFFIX = '-stories';
const DATA_PANE_SUFFIX = '-data';
const RADDECS_PANE_SUFFIX = '-raddecs';
const ASSOCIATIONS_PANE_SUFFIX = '-associations';
const FOOTER_SUFFIX = '-footer';
const SAME_AS_CLASS = 'btn-group dropup';
const DEFAULT_TITLE = 'Unknown';
const DEFAULT_SUBTITLE = '\u2665 structured data';
const LIST_GROUP_CLASS = 'list-group list-group-flush';
const LIST_GROUP_ITEM_CLASS = 'list-group-item text-truncate';
const SIGNATURE_SEPARATOR = '/';
// Standard data properties (property: {icon, suffix}) in alphabetical order
const STANDARD_DATA_PROPERTIES = {
acceleration: { icon: "fas fa-rocket", suffix: "g",
transform: "xyzArray" },
batteryPercentage: { icon: "fas fa-battery-half", suffix: " %",
transform: "toFixed(0)" },
batteryVoltage: { icon: "fas fa-battery-half", suffix: " V",
transform: "toFixed(2)" },
humidityPercentage: { icon: "fas fa-water", suffix: " %",
transform: "toFixed(2)" },
macAddress: { icon: "fas fa-barcode", suffix: "" },
magneticField: { icon: "fas fa-magnet", suffix: "G",
transform: "xyzArray" },
name: { icon: "fas fa-info", suffix: "" },
temperature: { icon: "fas fa-thermometer-half", suffix: " \u2103",
transform: "toFixed(2)" },
timestamp: { icon: "fas fa-clock", suffix: "", transform: "timeOfDay" },
txPower: { icon: "fas fa-broadcast-tower", suffix: " dBm" },
uptime: { icon: "fas fa-stopwatch", suffix: " ms" },
url: { icon: "fas fa-link", suffix: "", transform: "hyperlink" },
visibleLight: { icon: "fas fa-lightbulb", suffix: "" }
};
// Render the given story in the given node
function render(story, node, options) {
let graph = story["@graph"];
let element = graph[0];
removeAllChildren(node);
renderImage(element, node);
renderBody(element, node);
renderFooter(element, node);
if(options && options.hasOwnProperty('listGroupItems') &&
Array.isArray(options.listGroupItems)) {
renderListGroup(options.listGroupItems, node);
}
}
// Render all the given data as tabs in a card
function renderAsTabs(node, stories, data, associations, raddecs, options) {
let id = node.getAttribute('id');
let isExistingRender = (node.getAttribute('selectedTab') !== null);
if(isExistingRender) {
let footer = document.querySelector('#' + id + FOOTER_SUFFIX);
updateFooterTitle(footer, stories, raddecs);
return updatePanes(node, stories, data, associations, raddecs, options);
}
removeAllChildren(node);
let header = createElement('div', HEADER_CLASS);
let navs = createElement('ul', 'nav nav-tabs card-header-tabs');
let body = createElement('div', TAB_BODY_CLASS);
let panes = createElement('div', 'tab-content overflow-auto');
let footer = createElement('div');
footer.setAttribute('id', id + FOOTER_SUFFIX);
updateFooterTitle(footer, stories, raddecs);
let hasActiveTab = false;
hasActiveTab |= renderStoryTab(navs, panes, stories, hasActiveTab, id);
hasActiveTab |= renderDataTab(navs, panes, data, hasActiveTab, id);
hasActiveTab |= renderAssociationsTab(navs, panes, associations,
hasActiveTab, id);
hasActiveTab |= renderRaddecTab(navs, panes, raddecs, hasActiveTab, id);
node.setAttribute('selectedTab', 'none');
node.appendChild(header);
header.appendChild(navs);
node.appendChild(body);
body.appendChild(panes);
node.appendChild(footer);
}
// Update only the panes of an existing render as tabs
function updatePanes(node, stories, data, associations, raddecs, options) {
let id = node.getAttribute('id');
let selectedTab = node.getAttribute('selectedTab');
let storyPane = document.querySelector('#' + id + STORIES_PANE_SUFFIX);
let dataPane = document.querySelector('#' + id + DATA_PANE_SUFFIX);
let associationsPane = document.querySelector('#' + id +
ASSOCIATIONS_PANE_SUFFIX);
let raddecPane = document.querySelector('#' + id + RADDECS_PANE_SUFFIX);
// TODO: observe selectedTab
renderStoryTabPaneContent(storyPane, stories);
renderDataTabPaneContent(dataPane, data);
renderAssociationsTabPaneContent(associationsPane, associations);
renderRaddecTabPaneContent(raddecPane, raddecs);
}
// Update the footer title with the story name or transmitterId
function updateFooterTitle(footer, stories, raddecs) {
let footerTitle = DEFAULT_TITLE;
let additionalFooterClasses = ' ';
let isVirginFooter = (footer.innerHTML === '');
let isNewTitle = false;
if(Array.isArray(stories) && stories.length) {
footerTitle = determineStoryTitle(stories[0]);
}
else if(Array.isArray(raddecs) && raddecs.length) {
footerTitle = raddecs[0].transmitterId + SIGNATURE_SEPARATOR +
raddecs[0].transmitterIdType;
additionalFooterClasses += 'monospace';
}
if(!isVirginFooter) {
let currentFooterTitle = footer.childNodes[1].textContent;
isNewTitle = (footerTitle !== currentFooterTitle);
}
if(isVirginFooter || isNewTitle) {
let element = {};
let titleClass = 'ml-4 text-wrap' + additionalFooterClasses;
let titleSpan = createElement('span', titleClass, footerTitle);
if((stories.length > 0) && stories[0].hasOwnProperty("@graph") &&
stories[0]["@graph"][0]) {
element = stories[0]["@graph"][0];
}
footer.innerHTML = '';
renderSameAs(element, footer);
footer.appendChild(titleSpan);
footer.setAttribute('class', FOOTER_CLASS);
}
}
// Remove all children of the given node
function removeAllChildren(node) {
while(node.firstChild) {
node.removeChild(node.firstChild);
}
}
// Render the card image
function renderImage(element, node) {
let img = createElement('img', IMG_CLASS);
img.src = determineElementImageUrl(element);
node.appendChild(img);
}
// Render the card body
function renderBody(element, node) {
let body = createElement('div', BODY_CLASS);
node.appendChild(body);
renderTitle(element, body);
renderSubtitle(element, body);
}
// Render the card footer
function renderFooter(element, node) {
let footer = createElement('div', FOOTER_CLASS);
node.appendChild(footer);
renderSameAs(element, footer);
}
// Render the title (name)
function renderTitle(element, node) {
let title = createElement('h5', TITLE_CLASS,
determineElementTitle(element));
node.appendChild(title);
}
// Render the subtitle
function renderSubtitle(element, node) {
let text = DEFAULT_SUBTITLE;
if(element.hasOwnProperty("schema:jobTitle") &&
element.hasOwnProperty("schema:worksFor")) {
text = toString(element["schema:jobTitle"]) + ' @ ' +
toString(element["schema:worksFor"]);
}
else if(element.hasOwnProperty("schema:jobTitle")) {
text = toString(element["schema:jobTitle"]);
}
else if(element.hasOwnProperty("schema:worksFor")) {
text = toString(element["schema:worksFor"]);
}
else if(element.hasOwnProperty("schema:brand")) {
text = toString(element["schema:brand"]);
}
else if(element.hasOwnProperty("schema:manufacturer")) {
text = toString(element["schema:manufacturer"]);
}
else if(element.hasOwnProperty("schema:maximumAttendeeCapacity")) {
text = 'Capacity: ' + element["schema:maximumAttendeeCapacity"];
}
let subtitle = createElement('h6', SUBTITLE_CLASS, text);
node.appendChild(subtitle);
}
// Render the sameAs (links)
function renderSameAs(element, node) {
let dropup = createElement('div', SAME_AS_CLASS);
node.appendChild(dropup);
let button = createElement('button',
'btn btn-secondary btn-sm dropdown-toggle');
button.setAttribute('type', 'button');
button.setAttribute('data-toggle', 'dropdown');
button.setAttribute('aria-haspopup', 'true');
button.setAttribute('aria-expanded', 'false');
dropup.appendChild(button);
let i = createElement('i', 'fas fa-ellipsis-h');
button.appendChild(i);
let sameAsCount = 0;
if(element.hasOwnProperty("schema:sameAs")) {
let menu = createElement('div', 'dropdown-menu');
dropup.appendChild(menu);
let sameAs = element["schema:sameAs"];
if(typeof sameAs === 'string') {
sameAs = [ sameAs ];
}
sameAsCount = sameAs.length;
sameAs.forEach(function(url) {
renderSameAsMenuItem(url, menu);
});
}
else {
button.setAttribute('class',
'btn btn-dark btn-sm dropdown-toggle disabled');
}
let count = document.createTextNode('\u00a0\u00a0' + sameAsCount);
button.appendChild(count);
}
// Render the sameAs menu item (link)
function renderSameAsMenuItem(url, node) {
let a = createElement('a', 'dropdown-item');
a.setAttribute('href', url);
a.setAttribute('target', '_blank');
renderLinkIcon(url, a);
let space = document.createTextNode('\u00a0\u00a0');
a.appendChild(space);
let i = createElement('i', 'fas fa-external-link-alt');
a.appendChild(i);
let urlSnippet = document.createTextNode('\u00a0' + url.split('/')[2] +
'/\u2026');
a.appendChild(urlSnippet);
node.appendChild(a);
}
// Render the link icon, if known, based on the given URL
function renderLinkIcon(url, node) {
let iClass = 'fas fa-link';
if(url.includes('github.com')) {
iClass = 'fab fa-github';
}
else if(url.includes('twitter.com')) {
iClass = 'fab fa-twitter';
}
else if(url.includes('linkedin.com')) {
iClass = 'fab fa-linkedin';
}
else if(url.includes('facebook.com')) {
iClass = 'fab fa-facebook';
}
else if(url.includes('instagram.com')) {
iClass = 'fab fa-instagram';
}
let i = createElement('i', iClass);
node.appendChild(i);
}
// Render the list group items
function renderListGroup(listGroupItems, node) {
let listGroup = createElement('ul', LIST_GROUP_CLASS);
listGroupItems.forEach(function(item) {
let itemClass = LIST_GROUP_ITEM_CLASS;
if(item.hasOwnProperty('itemClass')) {
itemClass += ' ' + item.itemClass;
}
let listGroupItem = createElement('li', itemClass);
if(item.hasOwnProperty('iconClass')) {
let space = document.createTextNode('\u00a0\u00a0');
let i = createElement('i', item.iconClass);
listGroupItem.appendChild(i);
listGroupItem.appendChild(space);
}
let text = document.createTextNode(item.text);
listGroupItem.appendChild(text);
listGroup.appendChild(listGroupItem);
});
node.appendChild(listGroup);
}
// Render the story nav tab and tab pane
function renderStoryTab(navs, panes, stories, hasActiveTab, id) {
let isEmpty = !(Array.isArray(stories) && stories.length);
let isActive = !hasActiveTab && !isEmpty;
let i = createElement('i', 'fas fa-book-open');
let paneId = id + STORIES_PANE_SUFFIX;
let nav = createNavTab(i, '#' + paneId, isActive, isEmpty);
let pane = createNavPane(paneId, isActive);
renderStoryTabPaneContent(pane, stories);
navs.appendChild(nav);
panes.appendChild(pane);
return isActive;
}
// Render the story tab's pane content
function renderStoryTabPaneContent(pane, stories) {
let isEmpty = !(Array.isArray(stories) && stories.length);
removeAllChildren(pane);
if(!isEmpty) {
let imageUrl = determineStoryImageUrl(stories[0]);
let img = createElement('img', 'img-fluid mx-auto d-block');
img.setAttribute('src', imageUrl);
pane.appendChild(img);
// TODO: additional stories
}
}
// Render the data nav tab and tab pane
function renderDataTab(navs, panes, data, hasActiveTab, id) {
let isEmpty = !(Array.isArray(data) && data.length);
let isActive = !hasActiveTab && !isEmpty;
let i = createElement('i', 'fas fa-info');
let paneId = id + DATA_PANE_SUFFIX;
let nav = createNavTab(i, '#' + paneId, isActive, isEmpty);
let pane = createNavPane(paneId, isActive);
renderDataTabPaneContent(pane, data);
navs.appendChild(nav);
panes.appendChild(pane);
return isActive;
}
// Render the data tab's pane content
function renderDataTabPaneContent(pane, data) {
let isEmpty = !(Array.isArray(data) && data.length);
removeAllChildren(pane);
if(!isEmpty) {
pane.appendChild(createDataTable(data[0])); // TODO: additional data
}
}
// Render the associations nav tab and tab pane
function renderAssociationsTab(navs, panes, associations, hasActiveTab, id) {
let isEmpty = !(associations && Object.keys(associations).length);
let isActive = !hasActiveTab && !isEmpty;
let i = createElement('i', 'fas fa-list-alt');
let paneId = id + ASSOCIATIONS_PANE_SUFFIX;
let nav = createNavTab(i, '#' + paneId, isActive, isEmpty);
let pane = createNavPane(paneId, isActive);
renderAssociationsTabPaneContent(pane, associations);
navs.appendChild(nav);
panes.appendChild(pane);
return isActive;
}
// Render the association tab's pane content
function renderAssociationsTabPaneContent(pane, associations) {
let isEmpty = !(associations && Object.keys(associations).length);
removeAllChildren(pane);
if(!isEmpty) {
pane.appendChild(createAssociationsTable(associations));
}
}
// Render the raddec nav tab and tab pane
function renderRaddecTab(navs, panes, raddecs, hasActiveTab, id) {
let isEmpty = !(Array.isArray(raddecs) && raddecs.length);
let isActive = !hasActiveTab && !isEmpty;
let i = createElement('i', 'fas fa-wifi');
let paneId = id + RADDECS_PANE_SUFFIX;
let nav = createNavTab(i, '#' + paneId, isActive, isEmpty);
let pane = createNavPane(paneId, isActive);
renderRaddecTabPaneContent(pane, raddecs);
navs.appendChild(nav);
panes.appendChild(pane);
return isActive;
}
// Render the raddec tab's pane content
function renderRaddecTabPaneContent(pane, raddecs) {
let isEmpty = !(Array.isArray(raddecs) && raddecs.length);
removeAllChildren(pane);
if(!isEmpty) {
pane.appendChild(createRaddecTable(raddecs[0])); // TODO: additional
}
}
// Create a data table
function createDataTable(data) {
let table = createElement('table', 'table table-hover');
let tbody = createElement('tbody');
for(property in data) {
let value = data[property];
tbody.appendChild(createDataTableRow(property, value));
}
table.appendChild(tbody);
return table;
}
// Create an associations table
function createAssociationsTable(associations) {
let table = createElement('table', 'table table-hover');
let tbody = createElement('tbody');
let url = createElement('a', null, associations.url);
url.setAttribute('href', associations.url);
url.setAttribute('_target', 'blank');
table.appendChild(tbody);
tbody.appendChild(createTableRow('fas fa-link', null, url));
tbody.appendChild(createTableRow('fas fa-tags', null, associations.tags));
tbody.appendChild(createTableRow('fas fa-sitemap', null,
associations.directory));
tbody.appendChild(createTableRow('fas fa-map-marked-alt', null,
associations.position));
return table;
}
// Create a raddec table
function createRaddecTable(raddec) {
let strongest = raddec.rssiSignature[0] || {};
let rec = raddec.rssiSignature.length;
let dec = strongest.numberOfDecodings;
let pac = raddec.packets.length || '-';
let timestamp = new Date(raddec.timestamp).toLocaleTimeString();
let table = createElement('table', 'table table-hover');
let tbody = createElement('tbody');
table.appendChild(tbody);
tbody.appendChild(createTableRow('fas fa-barcode', null,
raddec.transmitterId));
tbody.appendChild(createTableRow('fas fa-signal', null,
strongest.rssi + ' dBm'));
tbody.appendChild(createTableRow('fas fa-barcode', null,
strongest.receiverId));
tbody.appendChild(createTableRow('fas fa-info-circle', null,
rec + ' / ' + dec + ' / ' + pac));
tbody.appendChild(createTableRow('fas fa-clock', null, timestamp));
return table;
}
// Create a table row
function createTableRow(headerIconClass, headerText, data) {
let tr = createElement('tr');
let th = createElement('th');
let td = createElement('td', 'monospace', data);
if(headerIconClass) {
th.appendChild(createElement('i', headerIconClass));
}
if(headerText) {
th.appendChild(document.createTextNode(headerText));
}
tr.appendChild(th);
tr.appendChild(td);
return tr;
}
// Create a data table row
function createDataTableRow(property, value) {
let tr = createElement('tr');
let th = createElement('th');
let td = createElement('td', 'monospace');
if(STANDARD_DATA_PROPERTIES.hasOwnProperty(property)) {
let dataRender = STANDARD_DATA_PROPERTIES[property];
let valueElement;
switch(dataRender.transform) {
case 'toFixed(0)':
valueElement = document.createTextNode(value.toFixed(0));
break;
case 'toFixed(2)':
valueElement = document.createTextNode(value.toFixed(2));
break;
case 'timeOfDay':
let timeOfDay = new Date(value).toLocaleTimeString();
valueElement = document.createTextNode(timeOfDay);
break;
case 'hyperlink':
valueElement = createElement('a', null, value);
valueElement.setAttribute('href', value);
valueElement.setAttribute('target', '_blank');
break;
case 'xyzArray':
let magnitude = Math.sqrt((value[0] * value[0]) +
(value[1] * value[1]) +
(value[2] * value[2])).toFixed(2) +
dataRender.suffix;
let axes = createElement('span', 'small text-muted',
value[0].toFixed(2) + dataRender.suffix +
'\u21d2 | ' + value[1].toFixed(2) +
dataRender.suffix + '\u21d7 | ' +
value[2].toFixed(2) + dataRender.suffix +
'\u21d1');
valueElement = createElement('span');
valueElement.appendChild(document.createTextNode(magnitude));
valueElement.appendChild(createElement('br'));
valueElement.appendChild(axes);
break;
default:
valueElement = document.createTextNode(value);
}
th.appendChild(createElement('i', dataRender.icon));
td.appendChild(valueElement);
if(dataRender.transform !== 'xyzArray') {
td.appendChild(document.createTextNode(dataRender.suffix));
}
}
else {
th.textContent = property;
td.textContent = value;
}
tr.appendChild(th);
tr.appendChild(td);
return tr;
}
// Create a nav tab
function createNavTab(content, href, isActive, isDisabled) {
let linkClass = 'nav-link';
if(isActive) {
linkClass = 'nav-link active';
}
else if(isDisabled) {
linkClass = 'nav-link disabled';
}
let nav = createElement('li', 'nav-item');
let a = createElement('a', linkClass, content);
a.setAttribute('data-toggle', 'tab');
a.setAttribute('href', href);
nav.appendChild(a);
return nav;
}
// Create a nav pane
function createNavPane(id, isActive) {
let paneClass = 'tab-pane fade';
if(isActive) {
paneClass = 'tab-pane fade show active';
}
let pane = createElement('div', paneClass);
pane.setAttribute('id', id);
return pane;
}
// Determine the title of the story
function determineStoryTitle(story) {
let graph = story["@graph"];
let element = graph[0];
return determineElementTitle(element);
}
// Determine the title of the element
function determineElementTitle(element) {
if(element.hasOwnProperty("schema:name")) {
return element["schema:name"];
}
else if(element.hasOwnProperty("schema:givenName") ||
element.hasOwnProperty("schema:familyName")) {
return (element["schema:givenName"] || '') + ' ' +
(element["schema:familyName"] || '');
}
else {
return DEFAULT_TITLE;
}
}
// Determine the image URL of the story
function determineStoryImageUrl(story) {
let graph = story["@graph"];
let element = graph[0];
return determineElementImageUrl(element);
}
// Determine the image URL of the element
function determineElementImageUrl(element) {
if(element.hasOwnProperty("schema:image")) {
return element["schema:image"];
}
else if(element.hasOwnProperty("schema:logo")) {
return element["schema:logo"];
}
return null;
}
// Return the given schema.org property as a string, if not already so
function toString(property) {
if(typeof property === 'string') {
return property;
}
if(property.hasOwnProperty("name")) {
return property["name"];
}
else if(property.hasOwnProperty("schema:name")) {
return property["schema:name"];
}
return '';
}
// Create an HTML element with optional class and content (text or element)
function createElement(tagName, className, content) {
let element = document.createElement(tagName);
if(className) {
element.setAttribute('class', className);
}
if(content) {
if(content instanceof Element || content instanceof Node) {
element.appendChild(content);
}
else {
element.textContent = content;
}
}
return element;
}
// Expose the following functions and variables
return {
render: render,
renderAsTabs: renderAsTabs,
determineImageUrl: determineStoryImageUrl,
determineTitle: determineStoryTitle
}
}());