diff --git a/README.md b/README.md index fa6ccbdda..ebbfeee29 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Head Start is a web-based knowledge mapping software intended to give researcher ### Client To get started, clone this repository. Next, duplicate the file `config.example.js` in the root folder and rename it to `config.js`. -Make sure to have `npm` version 6.11.3 installed (it comes with Node.js 10.17.0, best way to install is with [nvm](https://github.com/nvm-sh/nvm), `nvm install 10.17.0`) and run the following two commands to build the Headstart client: +Make sure to have installed `node` version >= 14.18.1 and `npm` version >=8.1.1 (best way to install is with [nvm](https://github.com/nvm-sh/nvm), `nvm install 14.18.1`) and run the following two commands to build the Headstart client: npm install npm run dev @@ -24,9 +24,12 @@ To run Headstart on a different server (e.g. Apache), you need to set the public * Dev: specify the full path including protocol, e.g. `http://localhost/headstart/dist` * Production: specify the full path excluding protocol, e.g. `//example.org/headstart/dist` -Point your browser to the following address: +Point your browser to one of the following addresses: - http://localhost:8080/examples/local_files/index.html + http://localhost:8080/local_files/ + http://localhost:8080/local_streamgraph/ + http://localhost:8080/project_website/base.html + http://localhost:8080/project_website/pubmed.html If everything has worked out, you should see the visualization shown above. @@ -42,7 +45,7 @@ See [Installing and configuring the server](doc/server_config.md) for instructio Maintainer: [Peter Kraker](https://github.com/pkraker) ([pkraker@openknowledgemaps.org](mailto:pkraker@openknowledgemaps.org)) -Authors: [Maxi Schramm](https://github.com/tanteuschi), [Christopher Kittel](https://github.com/chreman), [Asura Enkhbayar](https://github.com/Bubblbu), [Scott Chamberlain](https://github.com/sckott), [Rainer Bachleitner](https://github.com/rbachleitner), [Yael Stein](https://github.com/jaels), [Thomas Arrow](https://github.com/tarrow), [Mike Skaug](https://github.com/mikeskaug), [Philipp Weissensteiner](https://github.com/wpp), and the [Open Knowledge Maps team](http://openknowledgemaps.org/team) +Authors: [Maxi Schramm](https://github.com/tanteuschi), [Christopher Kittel](https://github.com/chreman), [Jan Konstant](https://github.com/konstiman), [Asura Enkhbayar](https://github.com/Bubblbu), [Scott Chamberlain](https://github.com/sckott), [Rainer Bachleitner](https://github.com/rbachleitner), [Yael Stein](https://github.com/jaels), [Thomas Arrow](https://github.com/tarrow), [Mike Skaug](https://github.com/mikeskaug), [Philipp Weissensteiner](https://github.com/wpp), and the [Open Knowledge Maps team](http://openknowledgemaps.org/team) ## Features diff --git a/examples/gsheets/data-config.js b/examples/gsheets/data-config.js index 42a636382..c3d909821 100644 --- a/examples/gsheets/data-config.js +++ b/examples/gsheets/data-config.js @@ -26,7 +26,6 @@ var data_config = { content_based: true, is_evaluation: true, - evaluation_service: ["matomo"], is_force_areas: true, area_force_alpha: 0.03, diff --git a/examples/local_streamgraph/README.md b/examples/local_streamgraph/README.md index fc62b3878..739487559 100644 --- a/examples/local_streamgraph/README.md +++ b/examples/local_streamgraph/README.md @@ -11,7 +11,7 @@ module.exports = { Then the process is the same as with the local files example: start the dev server npm start. When the build is done, the streamgraph can be accessed at -`http://localhost:8080/examples/local_streamgraph/index.html` +`http://localhost:8080/local_streamgraph/` In future, it might be nice to come up with a solution that doesn't involve config file, but uses for example a system variable or a specific npm run command. diff --git a/examples/project_website/base.html b/examples/project_website/base.html index 122a67f23..ba370c0ff 100644 --- a/examples/project_website/base.html +++ b/examples/project_website/base.html @@ -2,7 +2,7 @@ - + diff --git a/examples/project_website/data-config_base.js b/examples/project_website/data-config_base.js index 3907d6697..c9fd4d020 100644 --- a/examples/project_website/data-config_base.js +++ b/examples/project_website/data-config_base.js @@ -38,7 +38,6 @@ var data_config = { show_keywords: true, is_evaluation: true, - evaluation_service: ["matomo"], use_hypothesis: true, diff --git a/examples/project_website/data-config_pubmed.js b/examples/project_website/data-config_pubmed.js index 97e4f8710..d7aca734a 100644 --- a/examples/project_website/data-config_pubmed.js +++ b/examples/project_website/data-config_pubmed.js @@ -37,7 +37,6 @@ var data_config = { show_keywords: true, is_evaluation: true, - evaluation_service: ["matomo"], use_hypothesis: true, diff --git a/examples/project_website/pubmed.html b/examples/project_website/pubmed.html index f56c11460..03773ad87 100644 --- a/examples/project_website/pubmed.html +++ b/examples/project_website/pubmed.html @@ -2,7 +2,7 @@ <html lang="en"> <head> - <base href="//localhost:8080/examples/project_website/"> + <base href="//localhost:8080/project_website/"> <script type="text/javascript" src="./data-config_pubmed.js"></script> diff --git a/examples/viper/js/data-config_openaire.js b/examples/viper/js/data-config_openaire.js index 405117c30..57e853b56 100644 --- a/examples/viper/js/data-config_openaire.js +++ b/examples/viper/js/data-config_openaire.js @@ -64,7 +64,6 @@ var data_config = { }, is_evaluation: true, - evaluation_service: ["ga", "matomo"], use_hypothesis: true, diff --git a/vis/js/actions/index.js b/vis/js/actions/index.js index 16c72852e..0f2e16af2 100644 --- a/vis/js/actions/index.js +++ b/vis/js/actions/index.js @@ -20,7 +20,6 @@ export const NOT_QUEUED_IN_ANIMATION = [ "ZOOM_OUT", "SELECT_PAPER", "DESELECT_PAPER", - "DESELECT_PAPER_BACKLINK", "FILE_CLICKED", "SEARCH", "FILTER", @@ -29,20 +28,23 @@ export const NOT_QUEUED_IN_ANIMATION = [ export const zoomIn = ( selectedAreaData, - source = null, callback, - alreadyZoomed = false + alreadyZoomed = false, + isFromBackButton = false, + selectedPaperData = null ) => ({ type: "ZOOM_IN", selectedAreaData, - source, callback, alreadyZoomed, + isFromBackButton, + selectedPaperData, }); -export const zoomOut = (callback) => ({ +export const zoomOut = (callback, isFromBackButton = false) => ({ type: "ZOOM_OUT", callback, + isFromBackButton, }); /** @@ -97,10 +99,11 @@ export const filter = (id) => ({ type: "FILTER", id }); export const sort = (id) => ({ type: "SORT", id }); -export const selectPaper = (paper) => ({ +export const selectPaper = (paper, isFromBackButton = false) => ({ type: "SELECT_PAPER", safeId: paper.safe_id, paper, + isFromBackButton, }); export const deselectPaper = () => ({ type: "DESELECT_PAPER" }); @@ -114,10 +117,6 @@ export const showPreview = (paper) => ({ type: "SHOW_PREVIEW", paper }); export const hidePreview = () => ({ type: "HIDE_PREVIEW" }); -export const deselectPaperBacklink = () => ({ - type: "DESELECT_PAPER_BACKLINK", -}); - export const updateDimensions = (chart, list) => ({ type: "RESIZE", listHeight: list.height, diff --git a/vis/js/components/KnowledgeMap.js b/vis/js/components/KnowledgeMap.js index 7c7eb88f1..41d4eaf72 100644 --- a/vis/js/components/KnowledgeMap.js +++ b/vis/js/components/KnowledgeMap.js @@ -34,7 +34,6 @@ const KnowledgeMap = (props) => { }; const handleOtherAreaZoomIn = (bubble) => { - handleDeselectPaper(); handleZoomIn(bubble, true); trackMatomoEvent("Knowledge map", "Zoom in", "Bubble"); }; diff --git a/vis/js/components/Streamgraph.js b/vis/js/components/Streamgraph.js index 01eb979c5..27e10b192 100644 --- a/vis/js/components/Streamgraph.js +++ b/vis/js/components/Streamgraph.js @@ -25,7 +25,7 @@ import { TOOLTIP_OFFSET, } from "../utils/streamgraph"; -import { deselectPaper, zoomIn, zoomOut } from "../actions"; +import { zoomIn, zoomOut } from "../actions"; /** * Class representing the streamgraph visualization. @@ -541,7 +541,6 @@ const mapStateToProps = (state) => ({ const mapDispatchToProps = (dispatch) => ({ onAreaClick: (stream) => { - dispatch(deselectPaper()); dispatch( zoomIn({ title: stream.key, color: stream.color, docIds: stream.docIds }) ); diff --git a/vis/js/components/listentries/BasicListEntries.js b/vis/js/components/listentries/BasicListEntries.js index 4573c469b..15100ce9c 100644 --- a/vis/js/components/listentries/BasicListEntries.js +++ b/vis/js/components/listentries/BasicListEntries.js @@ -15,8 +15,7 @@ import { shorten } from "../../utils/string"; const BasicListEntries = ({ displayedData, handleZoomIn, - handleSelectPaper, - handleDeselectPaper, + handleSelectPaperWithZoom, handlePDFClick, handleAreaMouseover, handleAreaMouseout, @@ -31,16 +30,14 @@ const BasicListEntries = ({ if (disableClicks) { return; } - handleSelectPaper(paper); - handleZoomIn(paper); + handleSelectPaperWithZoom(paper); }; const handleAreaClick = (paper) => { if (disableClicks) { return; } - handleDeselectPaper(); - handleZoomIn(paper, "list-area"); + handleZoomIn(paper); }; return ( diff --git a/vis/js/components/listentries/ClassificationListEntries.js b/vis/js/components/listentries/ClassificationListEntries.js index 53e4ce698..d3ca14ac7 100644 --- a/vis/js/components/listentries/ClassificationListEntries.js +++ b/vis/js/components/listentries/ClassificationListEntries.js @@ -27,7 +27,7 @@ const ClassificationListEntries = ({ disableClicks, handleZoomIn, handleSelectPaper, - handleDeselectPaper, + handleSelectPaperWithZoom, handlePDFClick, handleAreaMouseover, handleAreaMouseout, @@ -37,9 +37,10 @@ const ClassificationListEntries = ({ if (disableClicks) { return; } - handleSelectPaper(paper); if (!isStreamgraph) { - handleZoomIn(paper); + handleSelectPaperWithZoom(paper); + } else { + handleSelectPaper(paper); } }; @@ -47,8 +48,7 @@ const ClassificationListEntries = ({ if (disableClicks) { return; } - handleDeselectPaper(); - handleZoomIn(paper, "list-area"); + handleZoomIn(paper); }; return ( diff --git a/vis/js/components/listentries/StandardListEntries.js b/vis/js/components/listentries/StandardListEntries.js index fa0b88e8b..f9decbf01 100644 --- a/vis/js/components/listentries/StandardListEntries.js +++ b/vis/js/components/listentries/StandardListEntries.js @@ -34,7 +34,7 @@ const StandardListEntries = ({ disableClicks, handleZoomIn, handleSelectPaper, - handleDeselectPaper, + handleSelectPaperWithZoom, handlePDFClick, handleAreaMouseover, handleAreaMouseout, @@ -44,9 +44,10 @@ const StandardListEntries = ({ if (disableClicks) { return; } - handleSelectPaper(paper); if (!isStreamgraph) { - handleZoomIn(paper); + handleSelectPaperWithZoom(paper); + } else { + handleSelectPaper(paper); } }; @@ -54,8 +55,7 @@ const StandardListEntries = ({ if (disableClicks) { return; } - handleDeselectPaper(); - handleZoomIn(paper, "list-area"); + handleZoomIn(paper); }; return ( diff --git a/vis/js/default-config.js b/vis/js/default-config.js index 81423a107..2a5545f12 100644 --- a/vis/js/default-config.js +++ b/vis/js/default-config.js @@ -94,8 +94,6 @@ var config = { show_loading_screen: false, //evaluation mode/events logging is_evaluation: false, - //which evaluation service to use. can also be an array. currently possible: "log", "matomo" and "ga" - evaluation_service: "log", //enable logging of mouseover events (use only temporarily as it creates A LOT of logging events) enable_mouseover_evaluation: false, //whether to embed the okmaps credit diff --git a/vis/js/headstart.js b/vis/js/headstart.js index 9128a0602..f062b251e 100644 --- a/vis/js/headstart.js +++ b/vis/js/headstart.js @@ -32,68 +32,6 @@ HeadstartFSM.prototype = { } }, - // TODO delete this completely - recordAction: function(id, category, action, user, timestamp, additional_params, post_data) { - - if(!config.is_evaluation) { - return; - } - - let services = config.evaluation_service; - - if(typeof services === "string") { - services = [services]; - } - - if (services.includes("log")) { - this.recordActionLog(category, action, id, user, timestamp, additional_params, post_data); - } - if (services.includes("ga")) { - this.recordActionGA(category, action, id, user, timestamp, additional_params, post_data); - } - }, - - recordActionLog: function(id, category, action, user, type, timestamp, additional_params, post_data) { - timestamp = (typeof timestamp !== 'undefined') ? (escape(timestamp)) : (""); - additional_params = (typeof additional_params !== 'undefined') ? ('&' + additional_params) : (""); - if(typeof post_data !== 'undefined') { - post_data = {post_data:post_data}; - } else { - post_data = {}; - } - - let php_script = config.server_url + "services/writeActionToLog.php"; - - $.ajax({ - url: php_script + - '?user=' + user + - '&category=' + category + - '&action=' + action + - '&item=' + encodeURI(id) + - '&type=' + type + - '&item_timestamp=' + timestamp + additional_params + '&jsoncallback=?', - type: "POST", - data: post_data, - dataType: "json", - success: function(output) { - console.log(output); - } - }); - }, - - recordActionGA: function(category, action, id) { - //gtag.js - if(typeof gtag === "function") { - gtag('event', action, { - 'event_category': category, - 'event_label': id - }); - //analytics.js - } else if (typeof ga === "function") { - ga('send', 'event', category, action, id); - } - }, - markProjectChanged: function (id) { let php_script = config.server_url + "services/markProjectChanged.php"; diff --git a/vis/js/intermediate.js b/vis/js/intermediate.js index 7c18f1f7e..124df1aa9 100644 --- a/vis/js/intermediate.js +++ b/vis/js/intermediate.js @@ -13,6 +13,8 @@ import { applyForceAreas, applyForcePapers, preinitializeStore, + zoomIn, + zoomOut, } from "./actions"; import { STREAMGRAPH_MODE } from "./reducers/chartType"; @@ -23,6 +25,10 @@ import logAction from "./utils/actionLogger"; import { getChartSize, getListSize } from "./utils/dimensions"; import Headstart from "./components/Headstart"; import { sanitizeInputData } from "./utils/data"; +import { createAnimationCallback } from "./utils/eventhandlers"; +import { removeQueryParams, handleUrlAction } from "./utils/url"; +import debounce from "./utils/debounce"; +import { handleTitleAction } from "./utils/title"; /** * Class to sit between the "old" mediator and the @@ -41,13 +47,18 @@ class Intermediate { createRepeatedInitializeMiddleware(this), createChartTypeMiddleware(), createRescaleMiddleware(rescaleCallback), - createRecordActionMiddleware() + createRecordActionMiddleware(), + createQueryParameterMiddleware(), + createPageTitleMiddleware(this) ); this.store = createStore(rootReducer, middleware); } renderFrontend(config) { + this.config = config; + this.originalTitle = document.title; + this.store.dispatch(preinitializeStore(config)); ReactDOM.render( @@ -56,20 +67,27 @@ class Intermediate { </Provider>, document.getElementById("app-container") ); + + window.addEventListener( + "popstate", + debounce(this.onBackButtonClick.bind(this), 300) + ); } initStore(config, context, mapData, streamData) { const { size, width, height } = getChartSize(config, context); const list = getListSize(config, context, size); - const sanitizedMapData = sanitizeInputData(mapData); + this.config = config; + this.sanitizedMapData = sanitizeInputData(mapData); + this.streamData = streamData; this.store.dispatch( initializeStore( config, context, - sanitizedMapData, - streamData, + this.sanitizedMapData, + this.streamData, size, width, height, @@ -87,6 +105,135 @@ class Intermediate { this.applyForceLayout(); } + + // enable this for ability to share link to a zoomed bubble / paper + // if (queryParams.has("paper")) { + // this.selectUrlPaper(); + // } else { + // this.zoomUrlArea(); + // } + // remove the following lines if the previous line is uncommented + const queryParams = new URLSearchParams(window.location.search); + const paramsToRemove = []; + if (queryParams.has("area")) { + paramsToRemove.push("area"); + } + if (queryParams.has("paper")) { + paramsToRemove.push("paper"); + } + if (paramsToRemove.length > 0) { + removeQueryParams(...paramsToRemove); + } + } + + /** + * Function for when the browser back button is clicked. + * + * This function is the reason zoom-in and zoom-out actions are also queued + * in the queue middleware. + * + * For a better user experience, it should be debounced. + */ + onBackButtonClick() { + const queryParams = new URLSearchParams(window.location.search); + + if (!queryParams.has("area")) { + if (this.config.is_streamgraph && queryParams.has("paper")) { + removeQueryParams("paper"); + } + this.store.dispatch( + zoomOut(createAnimationCallback(this.store.dispatch), true) + ); + + return; + } + + // this can be optimized: if the area is the same as before, simply + // deselect the paper or select a different one + + if (queryParams.has("area") && !queryParams.has("paper")) { + this.zoomUrlArea(); + + return; + } + + if (queryParams.has("paper")) { + this.selectUrlPaper(); + } + } + + /** + * Selects the paper that's specified in the query params. + */ + selectUrlPaper() { + if (this.config.is_streamgraph) { + // paper cannot be selected in streamgraph + removeQueryParams("area", "paper"); + return; + } + + const params = new URLSearchParams(window.location.search); + + if (!params.has("paper")) { + return; + } + + const zoomedPaper = params.get("paper"); + + const paper = this.sanitizedMapData.find((p) => p.safe_id === zoomedPaper); + + if (!paper) { + return; + } + + this.store.dispatch( + zoomIn( + { title: paper.area, uri: paper.area_uri }, + createAnimationCallback(this.store.dispatch), + this.store.getState().zoom, + true, + paper + ) + ); + } + + /** + * Zooms into an area that's specified in the query params. + */ + zoomUrlArea() { + const params = new URLSearchParams(window.location.search); + + if (!params.has("area")) { + return; + } + + const zoomedArea = params.get("area"); + + if (this.config.is_streamgraph) { + // the instant zoom in doesn't work for streamgraph, because the data is not processed here yet (this.streamData) + // proper data processing refactoring is necessary + // this is a workaround that simply zooms out + removeQueryParams("area"); + this.store.dispatch( + zoomOut(createAnimationCallback(this.store.dispatch), true) + ); + return; + } + + const area = this.sanitizedMapData.find((a) => a.area_uri == zoomedArea); + + if (!area) { + return; + } + + this.store.dispatch( + zoomIn( + { title: area.area, uri: area.area_uri }, + createAnimationCallback(this.store.dispatch), + this.store.getState().zoom, + true + ) + ); } // triggered on window resize @@ -132,7 +279,10 @@ function createActionQueueMiddleware(intermediate) { if (getState().animation !== null) { if (!ALLOWED_IN_ANIMATION.includes(action.type)) { - if (!NOT_QUEUED_IN_ANIMATION.includes(action.type)) { + if ( + !NOT_QUEUED_IN_ANIMATION.includes(action.type) || + action.isFromBackButton + ) { actionQueue.push({ ...action }); } action.canceled = true; @@ -145,7 +295,13 @@ function createActionQueueMiddleware(intermediate) { if (action.type === "STOP_ANIMATION") { while (actionQueue.length > 0) { const queuedAction = actionQueue.shift(); - dispatch(queuedAction); + if (!NOT_QUEUED_IN_ANIMATION.includes(queuedAction.type)) { + dispatch(queuedAction); + } else { + requestAnimationFrame(() => { + dispatch(queuedAction); + }); + } } } @@ -266,8 +422,35 @@ function createFileChangeMiddleware() { function createRecordActionMiddleware() { return function ({ getState }) { return (next) => (action) => { - const state = getState(); - logAction(action, state); + if (!action.canceled) { + const state = getState(); + logAction(action, state); + } + return next(action); + }; + }; +} + +function createQueryParameterMiddleware() { + return function () { + return (next) => (action) => { + if (!action.canceled && !action.isFromBackButton) { + handleUrlAction(action); + } + + return next(action); + }; + }; +} + +function createPageTitleMiddleware(itm) { + return function ({ getState }) { + return (next) => (action) => { + if (!action.canceled && !action.isFromBackButton) { + const state = getState(); + handleTitleAction(action, itm.originalTitle, state); + } + return next(action); }; }; diff --git a/vis/js/mediator.js b/vis/js/mediator.js index a68ec6a4b..2e49f1eb1 100644 --- a/vis/js/mediator.js +++ b/vis/js/mediator.js @@ -32,7 +32,7 @@ var MyMediator = function() { this.fileData = []; this.mediator = new Mediator(); this.manager = new ModuleManager(); - this.intermediate_layer = new Intermediate(this.rescale_map, this.record_action); + this.intermediate_layer = new Intermediate(this.rescale_map); this.init(); this.init_state(); }; @@ -55,9 +55,6 @@ MyMediator.prototype = { // bubbles events this.mediator.subscribe("bubbles_update_data_and_areas", this.bubbles_update_data_and_areas); - - // misc - this.mediator.subscribe("record_action", this.record_action); }, init_state: function() { @@ -151,10 +148,6 @@ MyMediator.prototype = { mediator.render_frontend(); }, - record_action: function(id, category, action, user, type, timestamp, additional_params, post_data) { - window.headstartInstance.recordAction(id, category, action, user, type, timestamp, additional_params, post_data); - }, - dimensions_update: function() { mediator.intermediate_layer.updateDimensions(config, io.context); }, diff --git a/vis/js/reducers/modals.js b/vis/js/reducers/modals.js index 109a0fc46..8b2e0dc54 100644 --- a/vis/js/reducers/modals.js +++ b/vis/js/reducers/modals.js @@ -94,6 +94,17 @@ const modals = ( ...state, openCitationModal: false, }; + case "ZOOM_IN": + case "ZOOM_OUT": + return { + ...state, + openInfoModal: false, + openEmbedModal: false, + openViperEditModal: false, + openCitationModal: false, + previewedPaper: null, + }; + default: return state; } diff --git a/vis/js/reducers/selectedPaper.js b/vis/js/reducers/selectedPaper.js index 4a669b6f8..826f478c8 100644 --- a/vis/js/reducers/selectedPaper.js +++ b/vis/js/reducers/selectedPaper.js @@ -4,16 +4,20 @@ const selectedPaper = (state = null, action) => { } switch (action.type) { + case "INITIALIZE": case "ZOOM_OUT": - return null; case "SCALE": - return null; - case "INITIALIZE": - return null; case "DESELECT_PAPER": return null; - case "DESELECT_PAPER_BACKLINK": - return null; + case "ZOOM_IN": { + if (!action.selectedPaperData) { + return null; + } + + return { + safeId: action.selectedPaperData.safe_id, + }; + } case "SELECT_PAPER": return { safeId: action.safeId, diff --git a/vis/js/utils/actionLogger.js b/vis/js/utils/actionLogger.js index 628083b46..60df1e71d 100644 --- a/vis/js/utils/actionLogger.js +++ b/vis/js/utils/actionLogger.js @@ -18,9 +18,55 @@ const logAction = (action, state) => { // TODO trackSiteSearch ? // https://developer.matomo.org/guides/tracking-javascript-guide return trackMatomoEvent("List controls", "Search", "Search box"); + case "ZOOM_IN": + return trackZoomIn(action, state); + case "ZOOM_OUT": + return trackZoomOut(action); + case "SELECT_PAPER": + return trackSelectPaper(action); + case "DESELECT_PAPER": + return trackDeselectPaper(action); default: return; } }; export default logAction; + +const trackZoomIn = (action, state) => { + if (!action.isFromBackButton) { + return; + } + + if (action.selectedPaperData) { + return trackSelectPaper(action); + } + + if (state.selectedPaper) { + return trackDeselectPaper(action); + } + + trackMatomoEvent("Browser buttons", "Zoom in", "Back/Forward button"); +}; + +const trackZoomOut = (action) => { + if (action.isFromBackButton) { + trackMatomoEvent("Browser buttons", "Zoom out", "Back/Forward button"); + } +}; + +const trackSelectPaper = (action) => { + if (action.isFromBackButton) { + trackMatomoEvent("Browser buttons", "Select paper", "Back/Forward button"); + } +}; + +const trackDeselectPaper = (action) => { + if (action.isFromBackButton) { + trackMatomoEvent( + "Browser buttons", + "Deselect paper", + "Back/Forward button" + ); + } +}; diff --git a/vis/js/utils/eventhandlers.js b/vis/js/utils/eventhandlers.js index 0576694e3..4ac2d9c0f 100644 --- a/vis/js/utils/eventhandlers.js +++ b/vis/js/utils/eventhandlers.js @@ -5,7 +5,6 @@ import { showPreview, selectPaper, deselectPaper, - deselectPaperBacklink, stopAnimation, hoverBubble, hoverPaper, @@ -16,12 +15,10 @@ import { * @param {Function} dispatch */ export const mapDispatchToListEntriesProps = (dispatch) => ({ - // TODO remove the source after refactoring - handleZoomIn: (paper, source = null) => + handleZoomIn: (paper) => dispatch( zoomIn( { title: paper.area, uri: paper.area_uri }, - source, createAnimationCallback(dispatch) ) ), @@ -29,8 +26,18 @@ export const mapDispatchToListEntriesProps = (dispatch) => ({ handleAreaMouseover: (paper) => dispatch(highlightArea(paper)), handleAreaMouseout: () => dispatch(highlightArea(null)), handleSelectPaper: (paper) => dispatch(selectPaper(paper)), + handleSelectPaperWithZoom: (paper) => + dispatch( + zoomIn( + { title: paper.area, uri: paper.area_uri }, + createAnimationCallback(dispatch), + false, + false, + paper + ) + ), handleDeselectPaper: () => dispatch(deselectPaper()), - handleBacklinkClick: () => dispatch(deselectPaperBacklink()), + handleBacklinkClick: () => dispatch(deselectPaper()), }); /** @@ -42,7 +49,6 @@ export const mapDispatchToMapEntriesProps = (dispatch) => ({ dispatch( zoomIn( { title: area.title, uri: area.area_uri }, - null, createAnimationCallback(dispatch), alreadyZoomed ) @@ -51,7 +57,8 @@ export const mapDispatchToMapEntriesProps = (dispatch) => ({ handleDeselectPaper: () => dispatch(deselectPaper()), handleSelectPaper: (paper) => dispatch(selectPaper(paper)), changeBubbleOrder: (uri) => dispatch(hoverBubble(uri)), - changePaperOrder: (safeId, enlargeFactor) => dispatch(hoverPaper(safeId, enlargeFactor)), + changePaperOrder: (safeId, enlargeFactor) => + dispatch(hoverPaper(safeId, enlargeFactor)), }); /** diff --git a/vis/js/utils/title.js b/vis/js/utils/title.js new file mode 100644 index 000000000..952dab952 --- /dev/null +++ b/vis/js/utils/title.js @@ -0,0 +1,61 @@ +/** + * Sets correct page title based on the current action and state. + * + * @param {Object} action the Redux action object + * @param {string} defaultTitle the original page title Headstart has when it loads + * @param {Object} state the Redux state object + */ +export const handleTitleAction = (action, defaultTitle, state) => { + switch (action.type) { + case "ZOOM_IN": + document.title = getZoomInTitle( + action.selectedAreaData, + action.selectedPaperData, + defaultTitle + ); + return; + case "ZOOM_OUT": + document.title = defaultTitle; + return; + case "SELECT_PAPER": + document.title = getSelectPaperTitle(action.paper, defaultTitle); + return; + case "DESELECT_PAPER": + document.title = getDeselectPaperTitle(defaultTitle, state); + return; + default: + return; + } +}; + +const getZoomInTitle = (areaData, paperData, defaultTitle) => { + if (!areaData) { + return document.title; + } + + if (paperData) { + return `${paperData.title} | ${defaultTitle}`; + } + + return `${areaData.title} | ${defaultTitle}`; +}; + +const getSelectPaperTitle = (paperData, defaultTitle) => { + if (!paperData) { + return document.title; + } + + return `${paperData.title} | ${defaultTitle}`; +}; + +const getDeselectPaperTitle = (defaultTitle, state) => { + if (state.selectedBubble) { + return getZoomInTitle( + { title: state.selectedBubble.title }, + null, + defaultTitle + ); + } + + return defaultTitle; +}; diff --git a/vis/js/utils/url.js b/vis/js/utils/url.js new file mode 100644 index 000000000..9b9b9e89d --- /dev/null +++ b/vis/js/utils/url.js @@ -0,0 +1,100 @@ +/** + * Adds a parameter into the URL query (without redirect). + * @param {string} key parameter name + * @param {string} value parameter value + */ +export const addQueryParam = (key, value) => { + const url = new URL(window.location.href); + url.searchParams.set(key, value); + + window.history.pushState("", "", url.pathname + url.search); +}; + +/** + * Removes a parameter from the URL query (without redirect). + * @param {string} key parameter name + */ +export const removeQueryParams = (...keys) => { + const url = new URL(window.location.href); + keys.forEach((key) => { + url.searchParams.delete(key); + }); + + window.history.pushState("", "", url.pathname + url.search); +}; + +const addRemoveQueryParams = (paramsToAdd, paramsToRemove) => { + const url = new URL(window.location.href); + + Object.keys(paramsToAdd).forEach((key) => { + url.searchParams.set(key, paramsToAdd[key]); + }); + + paramsToRemove.forEach((key) => { + url.searchParams.delete(key); + }); + + window.history.pushState("", "", url.pathname + url.search); +}; + +/** + * Changes page url based on the current Redux action. + * + * @param {Object} action the Redux action object + */ +export const handleUrlAction = (action) => { + switch (action.type) { + case "ZOOM_IN": + return handleZoomIn(action); + case "ZOOM_OUT": + return handleZoomOut(); + case "SELECT_PAPER": + return handleSelectPaper(action); + case "DESELECT_PAPER": + return handleDeselectPaper(); + default: + return; + } +}; + +const handleZoomIn = (action) => { + if (!action.selectedAreaData) { + return; + } + + if (action.selectedPaperData) { + addRemoveQueryParams( + { + area: + typeof action.selectedAreaData.uri !== "undefined" + ? action.selectedAreaData.uri + : action.selectedAreaData.title, + paper: action.selectedPaperData.safe_id, + }, + [] + ); + + return; + } + + addRemoveQueryParams( + { + area: + typeof action.selectedAreaData.uri !== "undefined" + ? action.selectedAreaData.uri + : action.selectedAreaData.title, + }, + ["paper"] + ); +}; + +const handleZoomOut = () => removeQueryParams("area", "paper"); + +const handleSelectPaper = (action) => { + if (!action.safeId) { + return; + } + addQueryParam("paper", action.safeId); +}; + +const handleDeselectPaper = () => removeQueryParams("paper"); diff --git a/vis/test/component/list.test.js b/vis/test/component/list.test.js index 0971a2c7a..8601d54b5 100644 --- a/vis/test/component/list.test.js +++ b/vis/test/component/list.test.js @@ -17,7 +17,6 @@ import { zoomIn, selectPaper, highlightArea, - deselectPaperBacklink, showPreview, deselectPaper, } from "../../js/actions"; @@ -1071,7 +1070,7 @@ describe("List entries component", () => { it("triggers a correct backlink click action in linkedcat (zoomed)", () => { const PAPER = linkedcatData.find((p) => p.safe_id === "AC15093982"); - const EXPECTED_PAYLOAD = deselectPaperBacklink(); + const EXPECTED_PAYLOAD = deselectPaper(); const storeObject = setup( { list: [PAPER] }, { show: true }, diff --git a/vis/test/store/animation.test.js b/vis/test/store/animation.test.js index b7541d937..74e21a917 100644 --- a/vis/test/store/animation.test.js +++ b/vis/test/store/animation.test.js @@ -16,7 +16,7 @@ describe("animation state", () => { const INITIAL_STATE = null; const F = jest.fn(); - const result = reducer(INITIAL_STATE, zoomIn({}, undefined, F, false)); + const result = reducer(INITIAL_STATE, zoomIn({}, F, false)); const EXPECTED_RESULT = { type: "ZOOM_IN", diff --git a/vis/test/store/areas.test.js b/vis/test/store/areas.test.js index d8bc2357b..7cf137c45 100644 --- a/vis/test/store/areas.test.js +++ b/vis/test/store/areas.test.js @@ -79,7 +79,6 @@ describe("areas state", () => { zoomIn( { uri: INITIALIZED_STATE.list[0].area_uri }, undefined, - undefined, false ) ); diff --git a/vis/test/store/selectedPaper.test.js b/vis/test/store/selectedPaper.test.js index e2b85fd2c..d1fba873d 100644 --- a/vis/test/store/selectedPaper.test.js +++ b/vis/test/store/selectedPaper.test.js @@ -1,9 +1,4 @@ -import { - zoomOut, - selectPaper, - deselectPaper, - deselectPaperBacklink, -} from "../../js/actions"; +import { zoomOut, selectPaper, deselectPaper } from "../../js/actions"; import selectedPaperReducer from "../../js/reducers/selectedPaper"; @@ -14,7 +9,7 @@ describe("list state", () => { id: "some-id", safe_id: "some-safe-id", title: "some title", - } + }; const EXPECTED_ACTION = { type: "SELECT_PAPER", safeId: PAPER.safe_id, @@ -29,13 +24,6 @@ describe("list state", () => { }; expect(deselectPaper()).toEqual(EXPECTED_ACTION); }); - - it("should create a deselect paper backlink action", () => { - const EXPECTED_ACTION = { - type: "DESELECT_PAPER_BACKLINK", - }; - expect(deselectPaperBacklink()).toEqual(EXPECTED_ACTION); - }); }); describe("reducers", () => { @@ -77,21 +65,6 @@ describe("list state", () => { expect(result).toEqual(EXPECTED_STATE); }); - it("should deselect the paper (from backlink)", () => { - const PAPER = { - safe_id: "some-id", - }; - - const INITIAL_STATE = { - safeId: PAPER.safe_id, - }; - const EXPECTED_STATE = null; - - const result = selectedPaperReducer(INITIAL_STATE, deselectPaperBacklink()); - - expect(result).toEqual(EXPECTED_STATE); - }); - it("should deselect the paper (on zoom out)", () => { const PAPER = { safe_id: "some-id", diff --git a/vis/test/store/zoom.test.js b/vis/test/store/zoom.test.js index 0699fc4d9..e069caf71 100644 --- a/vis/test/store/zoom.test.js +++ b/vis/test/store/zoom.test.js @@ -13,36 +13,19 @@ describe("zoom state", () => { const EXPECTED_ACTION = { type: "ZOOM_IN", - source: null, selectedAreaData: DATA, alreadyZoomed: false, callback: undefined, + isFromBackButton: false, }; expect(zoomIn(DATA)).toEqual(EXPECTED_ACTION); }); - it("should create a zoom-in action with source", () => { - const DATA = { - title: "some title", - url: "http://example.com", - }; - - const SOURCE = "some-source"; - - const EXPECTED_ACTION = { - type: "ZOOM_IN", - source: SOURCE, - selectedAreaData: DATA, - alreadyZoomed: false, - callback: undefined, - }; - expect(zoomIn(DATA, SOURCE)).toEqual(EXPECTED_ACTION); - }); - it("should create a zoom-out action", () => { const EXPECTED_ACTION = { type: "ZOOM_OUT", callback: undefined, + isFromBackButton: false, }; expect(zoomOut()).toEqual(EXPECTED_ACTION); }); diff --git a/webpack.config.js b/webpack.config.js index 08f84a5c8..984f373ac 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -19,10 +19,12 @@ const common = { }, devServer: { - static: { directory: path.join( __dirname ) }, + static: { + directory: path.resolve(__dirname, 'examples/'), + }, allowedHosts: "all", host: "0.0.0.0", - devMiddleware: { publicPath: '/dist/' } + devMiddleware: { publicPath: '/dist/' }, }, resolve: {