@datawheel/canon-core
Version:
Reusable React environment and components for creating visualization engines.
232 lines (195 loc) • 7.36 kB
JSX
/* eslint react/display-name:0 */
import React from "react";
import {hydrate} from "react-dom";
import {createHistory} from "history";
import {HelmetProvider} from "react-helmet-async";
import {applyRouterMiddleware, Router, RouterContext, useRouterHistory} from "react-router";
import {syncHistoryWithStore} from "react-router-redux";
import {animateScroll} from "react-scroll";
import {I18nextProvider} from "react-i18next";
import {Provider} from "react-redux";
import {selectAll} from "d3-selection";
import createRoutes from "$app/routes";
import {middleware as reduxMiddleware} from "$app/store";
import configureStore from "./storeConfig";
import {LOADING_END, LOADING_START} from "./consts";
import preRenderMiddleware from "./middlewares/preRenderMiddleware";
import maybeRedirect from "./helpers/maybeRedirect";
import styles from "$app/style.yml";
/**
* Finds a Number value for a given style.yml string variable. The cssVarRegex
* tests for nested CSS vars (ie. subnav-height: "var(--nav-height)") and resolves
* as deep as needed. Fallback return value is 0.
* @private
*/
function parseStyle(str) {
const cssVarRegex = /var\(--([A-z\-]+)\)/g;
let val = styles[str];
while (cssVarRegex.exec(val)) {
str = val.replace(/var\(--([A-z\-]+)\)/g, "$1");
val = styles[str];
}
return parseFloat(val) || 0;
}
const {basename} = window.__INITIAL_STATE__.location;
const browserHistory = useRouterHistory(createHistory)({basename});
const store = configureStore(window.__INITIAL_STATE__, browserHistory, reduxMiddleware);
const history = syncHistoryWithStore(browserHistory, store);
const routes = createRoutes(store);
import i18n from "i18next";
import yn from "yn";
import defaultTranslations from "./i18n/canon";
import CanonProvider from "./CanonProvider";
const {locale, resources} = window.__INITIAL_STATE__.i18n;
const {CANON_LOGLOCALE, NODE_ENV, CANON_GOOGLE_OPTIMIZE} = window.__INITIAL_STATE__.env;
const name = window.__APP_NAME__;
const resourceObj = {canon: {[name]: defaultTranslations}};
if (locale !== "canon") resourceObj[locale] = {[name]: resources};
i18n
.init({
fallbackLng: "canon",
lng: locale,
debug: NODE_ENV !== "production" ? yn(CANON_LOGLOCALE) : false,
ns: [name],
defaultNS: name,
react: {
wait: true,
withRef: true
},
resources: resourceObj
});
let scrollTimeout;
/**
Scrolls to a page element if it exists on the page.
*/
function scrollToHash(hash, tries = 0) {
const maxTries = 5;
clearTimeout(scrollTimeout);
const elem = hash && hash.indexOf("#") === 0 ? document.getElementById(hash.slice(1)) : false;
if (elem) {
const top = elem.getBoundingClientRect().top;
if (top) {
const offset = Math.round(top - parseStyle("nav-height") - parseStyle("subnav-height"));
// if the element is not at zero, scroll to it's position
if (offset !== 0) {
animateScroll.scrollMore(offset, {
duration: Math.abs(offset) < window.innerHeight ? 200 : 0
});
}
// if the element is not focused, focus it!
if (elem !== document.activeElement) elem.focus();
// retry this whole process a few times, just in case
// elements above it change height onLoad and push
// the element up or down
if (tries < maxTries) {
scrollTimeout = setTimeout(() => {
scrollToHash(hash, tries + 1);
}, 200);
}
}
}
// if no element on the page has the requested hash,
// retry again in 100ms (could be added via JavaScript)
else if (tries < maxTries) {
scrollTimeout = setTimeout(() => {
scrollToHash(hash, tries + 1);
}, 100);
}
}
/**
Middleware that captures all router requests and detects the following:
* Smooth scrolling to anchor links
* Initiatlize SSR needs loading
*/
function renderMiddleware() {
return {
renderRouterContext: (child, props) => {
const {location} = props;
const needs = props.components.filter(comp => comp && (comp.need || comp.preneed || comp.postneed));
const chunks = props.components.filter(comp => comp && comp.preload && comp.load);
const {action, hash, pathname, query, search, state} = location;
// Launch Optimize activation event if client side navigation
function launchOptimizeEvent() {
if (CANON_GOOGLE_OPTIMIZE && !window.__SSR__ && window.dataLayer) {
window.dataLayer.push({event: "optimize.activate"});
}
}
/** */
function postRender() {
if (!window.__SSR__) {
if (typeof window.ga === "function") {
setTimeout(() => {
const trackers = window.ga.getAll().map(t => t.get("name"));
trackers
.forEach(key => {
window.ga(`${key}.set`, "title", document.title);
window.ga(`${key}.set`, "page", pathname + search);
window.ga(`${key}.send`, "pageview");
});
}, 0);
}
}
if (hash) scrollToHash(hash);
else window.scrollTo(0, 0);
}
if (action !== "REPLACE" || !Object.keys(query).length) {
selectAll(".d3plus-tooltip").remove();
launchOptimizeEvent();
if (window.__SSR__ || state === "HASH" || !needs.length && !chunks.length) {
postRender();
window.__SSR__ = false;
}
else {
store.dispatch({type: LOADING_START});
document.body.scrollTop = document.documentElement.scrollTop = 0;
// detects components wrapped in @loadable/component,
// and forces the load in order to detect needs
const preloadComponents = props.components
.map(comp => comp && comp.preload && comp.load ? comp.load() : false);
Promise.all(preloadComponents)
.then(comps => comps.map((loaded, i) => {
const rawComp = props.components[i];
return loaded ? rawComp.resolveComponent(loaded) : rawComp;
}))
.then(components => {
const newProps = Object.assign({}, props, {components});
preRenderMiddleware(store, newProps)
.then(() => {
store.dispatch({type: LOADING_END});
const idRedirect = maybeRedirect(query, props, store.getState());
if (idRedirect) props.router.push(idRedirect);
postRender();
});
});
}
}
return <RouterContext {...props} />;
}
};
}
const helmet = window.__HELMET_DEFAULT__;
/**
Wraps the top-level router component in the CanonProvider
*/
function createElement(Component, props) {
if (props.children && props.route.path === "/") {
return <CanonProvider router={props.router} helmet={helmet} locale={locale}>
<Component {...props} />
</CanonProvider>;
}
else {
return <Component {...props} />;
}
}
const root = document.getElementById("React-Container");
hydrate(
<HelmetProvider>
<I18nextProvider i18n={i18n}>
<Provider store={store}>
<Router createElement={createElement} history={history} render={applyRouterMiddleware(renderMiddleware())}>
{routes}
</Router>
</Provider>
</I18nextProvider>
</HelmetProvider>,
root);