diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..a0264b8 --- /dev/null +++ b/.babelrc @@ -0,0 +1,10 @@ +{ + "presets": [ + "es2015", + "stage-0", + "react" + ], + "ignore": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..b7dab5e --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +node_modules +build \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..056d18f --- /dev/null +++ b/.eslintrc @@ -0,0 +1,183 @@ +{ + "extends": [ + "eslint:recommended", + "plugin:react/recommended" + ], + "parser": "babel-eslint", + "parserOptions": { + "jsx": true, + "experimentalObjectRestSpread": true + }, + "env": { + "browser" : true, + "jquery" : true, + "node" : true, + "commonjs": true, + "es6" : true + }, + "rules": { + "arrow-parens": 0, + // require space before/after arrow function's arrow + "arrow-spacing": 0, + // verify super() callings in constructors + "constructor-super": 1, + // enforce the spacing around the * in generator functions + "generator-star-spacing": 1, + // disallow modifying variables of class declarations + "no-class-assign": 1, + // disallow modifying variables that are declared using const + "no-const-assign": 2, + // disallow duplicate name in class members + "no-dupe-class-members": 2, + // disallow to use this/super before super() calling in constructors. + "no-this-before-super": 1, + // require let or const instead of var + "no-var": 0, + // require method and property shorthand syntax for object literals + "object-shorthand": 1, + // suggest using arrow functions as callbacks + "prefer-arrow-callback": 0, + // suggest using of const declaration for variables that are never modified after declared + "prefer-const": 0, + // suggest using the spread operator instead of .apply() + "prefer-spread": 1, + // suggest using Reflect methods where applicable + /*"prefer-reflect": 0,*/ + // suggest using template literals instead of strings concatenation + "prefer-template": 0, + // disallow generator functions that do not have yield + "require-yield": 0, + + "init-declarations": 0, // enforce or disallow variable initializations at definition + "no-catch-shadow": 2, // disallow the catch clause parameter name being the same as a variable in the outer scope + "no-delete-var": 0, // disallow deletion of variables (recommended) + "no-label-var": 1, // disallow labels that share a name with a variable + "no-shadow-restricted-names": 1, // disallow shadowing of names such as arguments + "no-shadow": 1, // disallow declaration of variables already declared in the outer scope + "no-undef-init": 0, // disallow use of undefined when initializing variables + "no-undef": 2, // disallow use of undeclared variables unless mentioned in a /*global */ block (recommended) + "no-undefined": 0, // disallow use of undefined variable + "no-unused-vars": 1, // disallow declaration of variables that are not used in the code (recommended) + "no-use-before-define": 1, // disallow use of variables before they are defined + + "strict": [ 0, "global" ], + + "no-console": 0, + + // enforce spacing inside array brackets + "array-bracket-spacing": [0, "never"], + // disallow or enforce spaces inside of single line blocks + "block-spacing": 0, + // enforce one true brace style + "brace-style": [ 0, "1tbs" ], + // require camel case names + "camelcase": 0, + // enforce spacing before and after comma + "comma-spacing": 0, + // enforce one true comma style + "comma-style": 0, + // require or disallow padding inside computed properties + "computed-property-spacing": [ 0, "never" ], + // enforces consistent naming when capturing the current execution context + "consistent-this": [ 0, "that" ], + // enforce newline at the end of file, with no multiple empty lines + "eol-last": 0, + // require function expressions to have a name + "func-names": 0, + // enforces use of function declarations or expressions + "func-style": [ 0, "declaration" ], + // this option enforces minimum and maximum identifier lengths (variable names, property names etc.) + "id-length": 0, + // this option sets a specific tab width for your code + "indent": [1, 4], + // specify whether double or single quotes should be used in JSX attributes + "jsx-quotes": 0, + // enforces spacing between keys and values in object literal properties + "key-spacing": [0, { "beforeColon": false, "afterColon": true }], + // enforces empty lines around comments + "lines-around-comment": 0, + // disallow mixed "LF" and "CRLF" as linebreaks + "linebreak-style": [ 0, "unix" ], + // specify the maximum depth callbacks can be nested + "max-nested-callbacks": [0, 2], + // require a capital letter for constructors + "new-cap": 0, + // disallow the omission of parentheses when invoking a constructor with no arguments + "new-parens": 0, + // allow/disallow an empty newline after var statement + "newline-after-var": 0, + // disallow use of the Array constructor + "no-array-constructor": 0, + // disallow use of the continue statement + "no-continue": 0, + // disallow comments inline after code + "no-inline-comments": 0, + // disallow if as the only statement in an else block + "no-lonely-if": 0, + // disallow mixed spaces and tabs for indentation + "no-mixed-spaces-and-tabs": [ 2, false ], + // disallow multiple empty lines + "no-multiple-empty-lines": [ 0, {"max": 2} ], + // disallow nested ternary expressions + "no-nested-ternary": 0, + // disallow use of the Object constructor + "no-new-object": 0, + // disallow use of certain syntax in code + "no-restricted-syntax": 0, + // disallow space between function identifier and application + "no-spaced-func": 0, + // disallow the use of ternary operators + "no-ternary": 0, + // disallow trailing whitespace at the end of lines + "no-trailing-spaces": 1, + // disallow dangling underscores in identifiers + "no-underscore-dangle": 0, + // disallow the use of Boolean literals in conditional expressions + "no-unneeded-ternary": 0, + // require or disallow padding inside curly braces + "object-curly-spacing": [ 0, "never" ], + // allow just one var statement per function + "one-var": 0, + // require assignment operator shorthand where possible or prohibit it entirely + "operator-assignment": [ 0, "always" ], + // enforce operators to be placed before or after line breaks + "operator-linebreak": 0, + // enforce padding within blocks + "padded-blocks": 0, + // require quotes around object literal property names + "quote-props": 0, + // specify whether double or single quotes should be used + "quotes": [ 0, "double" ], + // require identifiers to match the provided regular expression + "id-match": 0, + // enforce spacing before and after semicolons + "semi-spacing": [ 2, {"before": false, "after": true} ], + // require or disallow use of semicolons instead of ASI + "semi": 0, + // sort variables within the same declaration block + "sort-vars": 0, + // require a space after certain keywords + "space-after-keywords": [ 0, "always" ], + // require a space before certain keywords + "space-before-keywords": 0, + // require or disallow space before blocks + "space-before-blocks": [ 0, "always" ], + // require or disallow space before function opening parenthesis + "space-before-function-paren": [ 0, "always" ], + // require or disallow spaces inside parentheses + "space-in-parens": [ 0, "never" ], + // require spaces around operators + "space-infix-ops": 0, + // require a space after return, throw, and case + "space-return-throw-case": 0, + // Require or disallow spaces before/after unary operators + "space-unary-ops": [ 0, { "words": true, "nonwords": false } ], + // require or disallow a space immediately following the // or /* in a comment + "spaced-comment": 0, + // require regex literals to be wrapped in parentheses + "wrap-regex": 0, + + // "react/jsx-uses-react": "error", + // "react/jsx-uses-vars": "error" + } +} diff --git a/.gitignore b/.gitignore index 00cbbdf..6dd32ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,59 +1,10 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Typescript v1 declaration files -typings/ - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env - +node_modules +build/js/*.* +build/index.html +build/config.json5 +tests/e2e/bin +tests/e2e/reports +tests/e2e/screenshots +selenium-debug.log + +!.gitkeep diff --git a/.vscode/cSpell.json b/.vscode/cSpell.json new file mode 100644 index 0000000..b7b927b --- /dev/null +++ b/.vscode/cSpell.json @@ -0,0 +1,28 @@ +// cSpell Settings +{ + // Version of the setting file. Always 0.1 + "version": "0.1", + // language - current active spelling language + "language": "en", + // words - list of words to be always considered correct + "words": [ + "browserslist", + "devtool", + "hir", + "Fhir", + "SNOMED", + "LOINC", + "NUBC", + "UMLS", + "parens", + "getpages", + "getpagesoffset", + "jdenticon" + ], + // flagWords - list of words to be always considered incorrect + // This is useful for offensive words and common spelling errors. + // For example "hte" should be "the" + "flagWords": [ + "hte" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 8870c21..5d8724c 100644 --- a/README.md +++ b/README.md @@ -1 +1,209 @@ -# patient-browser \ No newline at end of file +# patient-browser +App to browse sample patients +[DEMO](https://patient-browser.smarthealthit.org/index.html) + +## Usage +The patient browser is standalone static html5 app living at +https://patient-browser.smarthealthit.org that is supposed to be invoked in +dialog windows or iFrames giving you the ability to select patients. It +would typically be rendered in a popup window but an iFrame inside a +layered dialog is often a preferred option. In any case, the patient +browser will be in it's own window which will be in another domain, +thus the post messages are used for the cross-window communication. + +The host app (the one that invokes the patient browser) can also +"inject" some configuration to the patient browser to customize it. +Here is one commented example of how that works: + +```js +/** + * Opens the patient browser in popup window to select some patients + * @param {String} selection (optional) Comma-separated list of patient IDs + * to be pre-selected. This is a way to pass the + * current selection (if any) that the host app + * maintains. The user will see these IDs as selected + * and will be able to make changes to the selection. + * @return {Promise} Returns a promise that will eventually be resolved + * with the new selection. + */ +function selectPatients(selection) { + return new Promise(function(resolve, reject) { + + // The origin of the patient browser app + let origin = "https://patient-browser.smarthealthit.org"; + + // What config file to load + let config = "stu3-open-sandbox" + + // Popup height + let height = 700; + + // Popup width + let width = 1000; + + // Open the popup + let popup = window.open( + origin + ( + selection ? + `/index.html?config=${config}#/?_selection=${encodeURIComponent(selection)}` : + "" + ), + "picker", + [ + "height=" + height, + "width=" + width, + "menubar=0", + "resizable=1", + "status=0", + "top=" + (screen.height - height) / 2, + "left=" + (screen.width - width) / 2 + ].join(",") + ); + + // The function that handles incoming messages + const onMessage = function onMessage(e) { + + // only if the message is coming from the patient picker + if (e.origin === origin) { + + // OPTIONAL: Send your custom configuration options if needed + // when the patient browser says it is ready + if (e.data.type === 'ready') { + popup.postMessage({ + type: 'config', + data: { + submitStrategy: "manual", + // ... + } + }, '*'); + } + + // When the picker requests to be closed: + // 1. Stop listening for messages + // 2. Close the popup window + // 3. Resolve the promise with the new selection (if any) + else if (e.data.type === 'result' || e.data.type === 'close') { + window.removeEventListener('message', onMessage); + popup.close(); + resolve(e.data.data); + } + } + }; + + // Now just wait for the user to interact with the patient picker + window.addEventListener('message', onMessage); + }); +} + +// onDOMReady (assuming that jQuery is available): +jQuery(function($) { + + // A button that will open the picker when clicked + let button = $(".my-button"); + + // An input that will display the selected patient IDs + let input = $(".my-input"); + + button.on("click", function() { + let selection = input.val(); + selectPatients(selection).then(sel => { + + // ignore cancel and close cases + if (sel || sel === "") { + $("input", this).val(sel) + } + }) + }) +}); +``` + +## Configuration Options + +The patient browser is designed to load external config file while starting. This way you can change the settings without having to re-build the app. Additionally, the app can be told which config file to load using an `config` get parameter like so: + +**Official FHIR STU3 Picker:** +https://patient-browser.smarthealthit.org/index.html?config=stu3-open-sandbox + +**Official FHIR DSTU2 Picker:** +https://patient-browser.smarthealthit.org/index.html?config=dstu2-open-sandbox + +**HAPI FHIR STU3 Picker:** +https://patient-browser.smarthealthit.org/index.html?config=stu3-open-hapi + +**HAPI FHIR DSTU2 Picker:** +https://patient-browser.smarthealthit.org/index.html?config=dstu2-open-hapi + +If you need to support other servers you can just submit a pull request adding dedicated config file to this folder `/build/config`. +Note that these config files are in `json5` format which is like a loose version of JSON and you can even have comments inside it. + +Any config file might contain the following options: + +- `server` - an object describing the FHIR API server + - `server.url` - The base URL of the FHIR API server to use. Note that the picker will only work with open servers that do not require authorization. + - `server.type` - The FHIR version. Currently this can be `DSTU-2` or `STU-3`. + - `server.tags` - An array of tag objects to be rendered in the tags auto-complete menu. This defaults to an empty array and in that case the tag selection widget will not have a drop-down menu options but it will still allow you to search by typing some tag manually. In other words, using an empty array is like saying that we just don't know what tags (if any) are available on that server. The list of tags might look like this: + ```js + [ + { + // The actual tag + key : "pro-5-2017", + + // The label to render in the tags auto-complete menu + label: "PROm sample patients 5/2017" + }, + { + key : "smart-5-2017", + label: "SMART sample patients 5/2017" + }, + { + key : "synthea-5-2017", + label: "Synthea sample patients 5/2017" + }, + // ... + ] + ``` + If your server does not have any tags then the tag selector widget will be useless and it is better if you hide it - see the `hideTagSelector` option below. + - `server.conditions` - An object containing all the predefined medical conditions. Each condition is stored by it's unique key and has a shape similar to this one: + ```js + prediabetes: { + description: 'Prediabetes', + codes: { + 'SNOMED-CT': ['15777000'] + } + } + ``` + This is an empty object by default since we can't know what conditions are available on each server. We have that list pre-built for the smart sandbox servers but for the others you are expected to that yourself. + +- `patientsPerPage` - Patients per page. Defaults to `10`. +- `timeout` - AJAX requests timeout in milliseconds. Defaults to `20000`. +- `renderSelectedOnly` - Only the selected patients are rendered. Should be false or the preselected patient IDs should be passed to the window. Otherwise It will result in rendering no patients at all. Defaults to `false`. +- `fhirViewer` - If `fhirViewer.enabled` is true (then `fhirViewer.url and `fhirViewer.param` MUST be set) then clicking on the patient-related resources in detail view will open their source in that external viewer. Otherwise they will just be opened in new browser tab. Defaults to + ```js + { + enabled: false, + url : "http://docs.smarthealthit.org/fhir-viewer/index.html", + param : "url" + } + ``` +- `outputMode` - How to return the selection. Defaults to `id-list`. Options are: + - `id-list` - return the selection as comma-separated list of patient IDs. + - `id-array` - return the selection as an array of patient IDs. + - `patients` - return the selection as an array of patient JSON objects. +- `submitStrategy` - Defaults to `automatic`. Options are: + - `automatic` - Submit on change and defer that in some cases + - `manual` - Render a submit button +- hideTagSelector - If there are no tags in the server the tag selector will not be useful. You can hide the Tags tab by passing `true` here. + +## URL Options +Some of the options above plus some additional ones can be passed via the URL. The app recognizes two types of parameters - search parameters and hash parameters. The search parameters are those listed after the first `?` and hash parameters are listed after a `?` character that is preceded by a `#` character. In other words, the hash portion of the URL can have it's own query string portion. + +### Search parameters +This can contain `config` parameter plus some of the config variable described above - `patientsPerPage`, `submitStrategy`, `hideTagSelector`. + +The **config** option is the base name of the config file that should be loaded from `/build/config/`. Defaults to `stu3-open-sandbox`. + +### Hash parameters +- `_tab` - Which tab to open by default. Can be `tags`, `conditions` or `demographics`. If missing the Demographics tab will be activated. +- `_selection` - Comma-separated list of patient IDs to be rendered as selected. Note that this is only evaluated once, while the picker is loading. + + diff --git a/build/config/dstu2-open-hapi.json5 b/build/config/dstu2-open-hapi.json5 new file mode 100644 index 0000000..0e63ed4 --- /dev/null +++ b/build/config/dstu2-open-hapi.json5 @@ -0,0 +1,49 @@ +{ + server: { + // "DSTU-2" or "STU-3" + type: "DSTU-2", + + // Api URL + url: "http://fhirtest.uhn.ca/baseDstu2", + + // The pre-populated tags in the tag selector + tags: [ + // we don't know this yet + ], + + // The mediacal conditions available on this server + conditions: { + // we don't know this yet + } + }, + + // Records per page + patientsPerPage: 10, + + // AJAX requests timeout (ms) + timeout: 20000, + + // Only the selected patients are rendered. Should be false or the + // preselected patient IDs should be passed to the window. Otherwise + // It will result in rendering no patients at all. + renderSelectedOnly: false, + + // If enabled is true (then url and param MUST be set) then clicking on the + // patient-related resources in detail view will open their source in that + // external viewer. Otherwise they will just be opened in new browser tab. + fhirViewer: { + enabled: true, + url : "http://docs.smarthealthit.org/fhir-viewer/index.html", + param : "url" + }, + + // What to send when the OK dialog button is clicked. Possible values: + // "id-list" - comma-separated list of patient IDs (default) + // "id-array" - array of patient IDs + // "patients" - array of patient JSON objects + outputMode: "id-list", + + // "automatic" -> onChange plus defer in some cases + // "manual" -> render a submit button + submitStrategy: "manual" +} \ No newline at end of file diff --git a/build/config/dstu2-open-sandbox.json5 b/build/config/dstu2-open-sandbox.json5 new file mode 100644 index 0000000..74e6e63 --- /dev/null +++ b/build/config/dstu2-open-sandbox.json5 @@ -0,0 +1,372 @@ +{ + server: { + // "DSTU-2" or "STU-3" + type: "DSTU-2", + + // Api URL + url: "https://sb-fhir-dstu2.smarthealthit.org/api/smartdstu2/open", + + // The pre-populated tags in the tag selector - none on this server + tags: [ + { + key : "smart-8-2017", + label: "SMART sample patients 8/2017" + }, + { + key : "synthea-8-2017", + label: "Synthea sample patients 8/2017" + } + ], + + // The mediacal conditions available on this server + conditions: { + "568005" : {"description":"Tic disorder","codes":{"SNOMED-CT":["568005"]}}, + "1023001" : {"description":"Apnea","codes":{"SNOMED-CT":["1023001"]}}, + "1201005" : {"description":"Benign essential hypertension","codes":{"SNOMED-CT":["1201005"]}}, + "1475003" : {"description":"Herpes simplex without complication","codes":{"SNOMED-CT":["1475003"]}}, + "1755008" : {"description":"Old myocardial infarction","codes":{"SNOMED-CT":["1755008"]}}, + "3723001" : {"description":"Arthropathy","codes":{"SNOMED-CT":["3723001"]}}, + "4557003" : {"description":"Preinfarction syndrome","codes":{"SNOMED-CT":["4557003"]}}, + "4800001" : {"description":"Urine finding","codes":{"SNOMED-CT":["4800001"]}}, + "5935008" : {"description":"Contraceptive pill surveillance","codes":{"SNOMED-CT":["5935008"]}}, + "7620006" : {"description":"Crohn's disease of large bowel","codes":{"SNOMED-CT":["7620006"]}}, + "8517006" : {"description":"History of tobacco use","codes":{"SNOMED-CT":["8517006"]}}, + "8722008" : {"description":"Aortic insufficiency","codes":{"SNOMED-CT":["8722008"]}}, + "9826008" : {"description":"Conjunctivitis","codes":{"SNOMED-CT":["9826008"]}}, + "10509002": {"description":"Acute bronchitis","codes":{"SNOMED-CT":["10509002"]}}, + "10601006": {"description":"Pain in limb","codes":{"SNOMED-CT":["10601006"]}}, + "11934000": {"description":"Hypertensive disorder","codes":{"SNOMED-CT":["11934000"]}}, + "12063002": {"description":"Hemorrhage of rectum and anus","codes":{"SNOMED-CT":["12063002"]}}, + "13200003": {"description":"Peptic ulcer without hemorrhage, without perforation AND without obstruction","codes":{"SNOMED-CT":["13200003"]}}, + "13600006": {"description":"Hair and hair follicle diseases","codes":{"SNOMED-CT":["13600006"]}}, + "13645005": {"description":"Chronic obstructive lung disease","codes":{"SNOMED-CT":["13645005"]}}, + "14302001": {"description":"Absence of menstruation","codes":{"SNOMED-CT":["14302001"]}}, + "14760008": {"description":"Constipation","codes":{"SNOMED-CT":["14760008"]}}, + "15639000": {"description":"Single major depressive episode, moderate","codes":{"SNOMED-CT":["15639000"]}}, + "15805002": {"description":"Acute sinusitis","codes":{"SNOMED-CT":["15805002"]}}, + "16001004": {"description":"Otalgia","codes":{"SNOMED-CT":["16001004"]}}, + "16041008": {"description":"Ovarian failure","codes":{"SNOMED-CT":["16041008"]}}, + "16932000": {"description":"Nausea and vomiting","codes":{"SNOMED-CT":["16932000"]}}, + "17226007": {"description":"Adjustment disorder","codes":{"SNOMED-CT":["17226007"]}}, + "18165001": {"description":"Jaundice","codes":{"SNOMED-CT":["18165001"]}}, + "18818009": {"description":"Recurrent major depressive episodes, moderate","codes":{"SNOMED-CT":["18818009"]}}, + "18941000": {"description":"Oppositional defiant disorder","codes":{"SNOMED-CT":["18941000"]}}, + "19471005": {"description":"Lymphadenitis","codes":{"SNOMED-CT":["19471005"]}}, + "20301004": {"description":"Disturbance in speech","codes":{"SNOMED-CT":["20301004"]}}, + "21186006": {"description":"Chronic non-suppurative otitis media","codes":{"SNOMED-CT":["21186006"]}}, + "21522001": {"description":"Abdominal pain","codes":{"SNOMED-CT":["21522001"]}}, + "22298006": {"description":"Acute myocardial infarction","codes":{"SNOMED-CT":["22298006"]}}, + "23560001": {"description":"Other specified pervasive developmental disorders, current or active state","codes":{"SNOMED-CT":["23560001"]}}, + "23913003": {"description":"External hemorrhoids without complication","codes":{"SNOMED-CT":["23913003"]}}, + "24199005": {"description":"Clinical finding","codes":{"SNOMED-CT":["24199005"]}}, + "25064002": {"description":"Headache","codes":{"SNOMED-CT":["25064002"]}}, + "25374005": {"description":"Other and unspecified noninfectious gastroenteritis and colitis","codes":{"SNOMED-CT":["25374005"]}}, + "25569003": {"description":"Paroxysmal ventricular tachycardia","codes":{"SNOMED-CT":["25569003"]}}, + "26079004": {"description":"Abnormal involuntary movement","codes":{"SNOMED-CT":["26079004"]}}, + "26929004": {"description":"Alzheimer's disease","codes":{"SNOMED-CT":["26929004"]}}, + "28651003": {"description":"Orthostatic hypotension","codes":{"SNOMED-CT":["28651003"]}}, + "29857009": {"description":"Chest pain","codes":{"SNOMED-CT":["29857009"]}}, + "30473006": {"description":"Abdominal pain","codes":{"SNOMED-CT":["30473006"]}}, + "30800001": {"description":"Acute Vaginitis","codes":{"SNOMED-CT":["30800001"]}}, + "30989003": {"description":"Arthralgia of the lower leg","codes":{"SNOMED-CT":["30989003"]}}, + "31822004": {"description":"Urethritis","codes":{"SNOMED-CT":["31822004"]}}, + "32398004": {"description":"Bronchitis","codes":{"SNOMED-CT":["32398004"]}}, + "34014006": {"description":"Viral disease","codes":{"SNOMED-CT":["34014006"]}}, + "34095006": {"description":"Dehydration","codes":{"SNOMED-CT":["34095006"]}}, + "34486009": {"description":"Thyrotoxicosis without goiter OR other cause","codes":{"SNOMED-CT":["34486009"]}}, + "34649000": {"description":"Closed fracture of malar AND/OR maxillary bones","codes":{"SNOMED-CT":["34649000"]}}, + "34713006": {"description":"Vitamin D deficiency","codes":{"SNOMED-CT":["34713006"]}}, + "34840004": {"description":"Enthesopathy","codes":{"SNOMED-CT":["34840004"]}}, + "35183001": {"description":"Acute secretory otitis media","codes":{"SNOMED-CT":["35183001"]}}, + "35253001": {"description":"Child attention deficit disorder","codes":{"SNOMED-CT":["35253001"]}}, + "35489007": {"description":"Depressive disorder","codes":{"SNOMED-CT":["35489007"]}}, + "35678005": {"description":"Multiple joint pain","codes":{"SNOMED-CT":["35678005"]}}, + "36083008": {"description":"Sinus node dysfunction","codes":{"SNOMED-CT":["36083008"]}}, + "36971009": {"description":"Acute sinusitis","codes":{"SNOMED-CT":["36971009"]}}, + "37796009": {"description":"Migraine","codes":{"SNOMED-CT":["37796009"]}}, + "38341003": {"description":"Essential hypertension","codes":{"SNOMED-CT":["38341003"]}}, + "39772007": {"description":"Anal and rectal polyp","codes":{"SNOMED-CT":["39772007"]}}, + "39812007": {"description":"Contusion of forearm","codes":{"SNOMED-CT":["39812007"]}}, + "40055000": {"description":"Chronic sinusitis","codes":{"SNOMED-CT":["40055000"]}}, + "40930008": {"description":"Hypothyroidism","codes":{"SNOMED-CT":["40930008"]}}, + "41006004": {"description":"Depression","codes":{"SNOMED-CT":["41006004"]}}, + "42343007": {"description":"Congestive heart failure","codes":{"SNOMED-CT":["42343007"]}}, + "43116000": {"description":"Contact dermatitis and other eczema, unspecified cause","codes":{"SNOMED-CT":["43116000"]}}, + "43339004": {"description":"Hypokalemia","codes":{"SNOMED-CT":["43339004"]}}, + "43878008": {"description":"Streptococcal sore throat","codes":{"SNOMED-CT":["43878008"]}}, + "44054006": {"description":"Diabetes mellitus type 2","codes":{"SNOMED-CT":["44054006"]}}, + "44465007": {"description":"Sprain of ankle","codes":{"SNOMED-CT":["44465007"]}}, + "44808001": {"description":"Conduction disorder of the heart","codes":{"SNOMED-CT":["44808001"]}}, + "45326000": {"description":"Shoulder joint pain","codes":{"SNOMED-CT":["45326000"]}}, + "46635009": {"description":"Diabetes mellitus type 1","codes":{"SNOMED-CT":["46635009"]}}, + "46689006": {"description":"Hypertrophy of tonsils","codes":{"SNOMED-CT":["46689006"]}}, + "46775006": {"description":"Respiratory distress syndrome in the newborn","codes":{"SNOMED-CT":["46775006"]}}, + "48167000": {"description":"Amnesia","codes":{"SNOMED-CT":["48167000"]}}, + "48694002": {"description":"Anxiety","codes":{"SNOMED-CT":["48694002"]}}, + "49436004": {"description":"Atrial fibrillation","codes":{"SNOMED-CT":["49436004"]}}, + "49727002": {"description":"Cough","codes":{"SNOMED-CT":["49727002"]}}, + "50438001": {"description":"Peripheral vertigo","codes":{"SNOMED-CT":["50438001"]}}, + "50485007": {"description":"Low tension glaucoma","codes":{"SNOMED-CT":["50485007"]}}, + "51868009": {"description":"Duodenal ulcer without hemorrhage, without perforation AND without obstruction","codes":{"SNOMED-CT":["51868009"]}}, + "52073004": {"description":"Unspecified disorders of menstruation and other abnormal bleeding from female genital tract","codes":{"SNOMED-CT":["52073004"]}}, + "52448006": {"description":"Other persistent mental disorders due to conditions classified elsewhere","codes":{"SNOMED-CT":["52448006"]}}, + "53298000": {"description":"Hematuria syndrome","codes":{"SNOMED-CT":["53298000"]}}, + "53726008": {"description":"Acute conjunctivitis","codes":{"SNOMED-CT":["53726008"]}}, + "53741008": {"description":"Coronary arteriosclerosis","codes":{"SNOMED-CT":["53741008"]}}, + "54150009": {"description":"Acute upper respiratory infection","codes":{"SNOMED-CT":["54150009"]}}, + "54302000": {"description":"Disorder of breast","codes":{"SNOMED-CT":["54302000"]}}, + "55260003": {"description":"Calcaneal spur","codes":{"SNOMED-CT":["55260003"]}}, + "55525008": {"description":"Paralytic ileus","codes":{"SNOMED-CT":["55525008"]}}, + "55566008": {"description":"Accident","codes":{"SNOMED-CT":["55566008"]}}, + "55822004": {"description":"Hyperlipidemia","codes":{"SNOMED-CT":["55822004"]}}, + "56018004": {"description":"Wheezing","codes":{"SNOMED-CT":["56018004"]}}, + "56097005": {"description":"Migraine without aura","codes":{"SNOMED-CT":["56097005"]}}, + "56727007": {"description":"Vitiligo","codes":{"SNOMED-CT":["56727007"]}}, + "57643001": {"description":"Gastro-esophageal reflux disease with esophagitis","codes":{"SNOMED-CT":["57643001"]}}, + "58075000": {"description":"Contusion of toe","codes":{"SNOMED-CT":["58075000"]}}, + "59455009": {"description":"Acidosis","codes":{"SNOMED-CT":["59455009"]}}, + "60241006": {"description":"Female stress incontinence","codes":{"SNOMED-CT":["60241006"]}}, + "60700002": {"description":"Sensorineural hearing loss","codes":{"SNOMED-CT":["60700002"]}}, + "61582004": {"description":"Allergic rhinitis","codes":{"SNOMED-CT":["61582004"]}}, + "62315008": {"description":"Diarrhea","codes":{"SNOMED-CT":["62315008"]}}, + "63102001": {"description":"Visual disturbance","codes":{"SNOMED-CT":["63102001"]}}, + "64715009": {"description":"Hypertensive heart disease, unspecified, without heart failure","codes":{"SNOMED-CT":["64715009"]}}, + "64859006": {"description":"Osteoporosis","codes":{"SNOMED-CT":["64859006"]}}, + "65074000": {"description":"Chronic obstructive lung disease","codes":{"SNOMED-CT":["65074000"]}}, + "65363002": {"description":"otitis media","codes":{"SNOMED-CT":["65363002"]}}, + "66857006": {"description":"Hemoptysis","codes":{"SNOMED-CT":["66857006"]}}, + "67678004": {"description":"Acute atopic conjunctivitis","codes":{"SNOMED-CT":["67678004"]}}, + "68225006": {"description":"Alopecia Areata","codes":{"SNOMED-CT":["68225006"]}}, + "68235000": {"description":"Other diseases of nasal cavity and sinuses","codes":{"SNOMED-CT":["68235000"]}}, + "68272006": {"description":"Acute maxillary sinusitis","codes":{"SNOMED-CT":["68272006"]}}, + "68496003": {"description":"Benign neoplasm of colon","codes":{"SNOMED-CT":["68496003"]}}, + "68566005": {"description":"Urinary tract infectious disease","codes":{"SNOMED-CT":["68566005"]}}, + "68962001": {"description":"Myalgia and myositis, unspecified","codes":{"SNOMED-CT":["68962001"]}}, + "70153002": {"description":"Hemorrhoids","codes":{"SNOMED-CT":["70153002"]}}, + "71186008": {"description":"Croup","codes":{"SNOMED-CT":["71186008"]}}, + "71884009": {"description":"Precordial pain","codes":{"SNOMED-CT":["71884009"]}}, + "72552008": {"description":"Feeding problems in newborn","codes":{"SNOMED-CT":["72552008"]}}, + "73749009": {"description":"Neonatal jaundice associated with preterm delivery","codes":{"SNOMED-CT":["73749009"]}}, + "76376003": {"description":"Endometriosis of uterus","codes":{"SNOMED-CT":["76376003"]}}, + "76498008": {"description":"Asymptomatic postmenopausal status (age-related) (natural)","codes":{"SNOMED-CT":["76498008"]}}, + "77075001": {"description":"Primary open angle glaucoma","codes":{"SNOMED-CT":["77075001"]}}, + "78164000": {"description":"Feeding difficulties and mismanagement","codes":{"SNOMED-CT":["78164000"]}}, + "78267003": {"description":"Cocaine abuse","codes":{"SNOMED-CT":["78267003"]}}, + "78275009": {"description":"Unspecified sleep apnea","codes":{"SNOMED-CT":["78275009"]}}, + "78667006": {"description":"Dysthymia","codes":{"SNOMED-CT":["78667006"]}}, + "78737005": {"description":"Acute frontal sinusitis","codes":{"SNOMED-CT":["78737005"]}}, + "78809005": {"description":"Benign neoplasm of stomach","codes":{"SNOMED-CT":["78809005"]}}, + "79298009": {"description":"Single major depressive episode, mild","codes":{"SNOMED-CT":["79298009"]}}, + "79890006": {"description":"Anorexia","codes":{"SNOMED-CT":["79890006"]}}, + "79922009": {"description":"Epigastric pain","codes":{"SNOMED-CT":["79922009"]}}, + "80182007": {"description":"Irregular periods","codes":{"SNOMED-CT":["80182007"]}}, + "80394007": {"description":"Blood chemistry abnormal","codes":{"SNOMED-CT":["80394007"]}}, + "81302005": {"description":"General well-being finding","codes":{"SNOMED-CT":["81302005"]}}, + "81564005": {"description":"Chronic serous otitis media","codes":{"SNOMED-CT":["81564005"]}}, + "81680005": {"description":"Neck pain","codes":{"SNOMED-CT":["81680005"]}}, + "82271004": {"description":"Injury of head","codes":{"SNOMED-CT":["82271004"]}}, + "82272006": {"description":"Common cold","codes":{"SNOMED-CT":["82272006"]}}, + "82523003": {"description":"Congestive rheumatic heart failure","codes":{"SNOMED-CT":["82523003"]}}, + "83521008": {"description":"Dilated cardiomyopathy secondary to alcohol","codes":{"SNOMED-CT":["83521008"]}}, + "84027009": {"description":"Pernicious anemia","codes":{"SNOMED-CT":["84027009"]}}, + "84229001": {"description":"Malaise and fatigue","codes":{"SNOMED-CT":["84229001"]}}, + "85005007": {"description":"Cannabis dependence","codes":{"SNOMED-CT":["85005007"]}}, + "85848002": {"description":"Superficial injury of cornea","codes":{"SNOMED-CT":["85848002"]}}, + "85898001": {"description":"Cardiomyopathy","codes":{"SNOMED-CT":["85898001"]}}, + "87433001": {"description":"Pulmonary emphysema","codes":{"SNOMED-CT":["87433001"]}}, + "87522002": {"description":"Iron deficiency anemia","codes":{"SNOMED-CT":["87522002"]}}, + "88223008": {"description":"Chronic pulmonary heart disease","codes":{"SNOMED-CT":["88223008"]}}, + "88616000": {"description":"Acne","codes":{"SNOMED-CT":["88616000"]}}, + "89164003": {"description":"Breast lump","codes":{"SNOMED-CT":["89164003"]}}, + "89765005": {"description":"Tobacco use disorder","codes":{"SNOMED-CT":["89765005"]}}, + "90392009": {"description":"Spasm","codes":{"SNOMED-CT":["90392009"]}}, + "90458007": {"description":"Internal hemorrhoids without mention of complication","codes":{"SNOMED-CT":["90458007"]}}, + "90560007": {"description":"Gout","codes":{"SNOMED-CT":["90560007"]}}, + "90708001": {"description":"Unspecified disorder of kidney and ureter","codes":{"SNOMED-CT":["90708001"]}}, + "91175000": {"description":"Seizure","codes":{"SNOMED-CT":["91175000"]}}, + "91487003": {"description":"Diaper rash","codes":{"SNOMED-CT":["91487003"]}}, + "92359006": {"description":"Benign neoplasm of skin of face","codes":{"SNOMED-CT":["92359006"]}}, + "92380000": {"description":"Benign neoplasm of skin of trunk","codes":{"SNOMED-CT":["92380000"]}}, + "93616000": {"description":"Intramural leiomyoma of uterus","codes":{"SNOMED-CT":["93616000"]}}, + "95280005": {"description":"Subserous leiomyoma of uterus","codes":{"SNOMED-CT":["95280005"]}}, + "95617006": {"description":"Perinatal cyanotic attacks","codes":{"SNOMED-CT":["95617006"]}}, + "102588006": {"description":"Painful respiration","codes":{"SNOMED-CT":["102588006"]}}, + "102589003": {"description":"Chest pain","codes":{"SNOMED-CT":["102589003"]}}, + "102594003": {"description":"Abnormal ECG","codes":{"SNOMED-CT":["102594003"]}}, + "108365000": {"description":"Unspecified local infection of skin and subcutaneous tissue","codes":{"SNOMED-CT":["108365000"]}}, + "111583006": {"description":"White blood cell disorder","codes":{"SNOMED-CT":["111583006"]}}, + "125598003": {"description":"Other and unspecified injury to elbow, forearm, and wrist","codes":{"SNOMED-CT":["125598003"]}}, + "125649002": {"description":"Open wound of forearm without complication","codes":{"SNOMED-CT":["125649002"]}}, + "126926005": {"description":"Neoplasm of breast","codes":{"SNOMED-CT":["126926005"]}}, + "128045006": {"description":"Cellulitis and abscess of unspecified sites","codes":{"SNOMED-CT":["128045006"]}}, + "128302006": {"description":"Chronic hepatitis C","codes":{"SNOMED-CT":["128302006"]}}, + "160303001": {"description":"FH: Diabetes mellitus","codes":{"SNOMED-CT":["160303001"]}}, + "161591004": {"description":"H/O: penicillin allergy","codes":{"SNOMED-CT":["161591004"]}}, + "161891005": {"description":"Backache","codes":{"SNOMED-CT":["161891005"]}}, + "162031009": {"description":"Dyspepsia and other specified disorders of function of stomach","codes":{"SNOMED-CT":["162031009"]}}, + "162116003": {"description":"Finding of frequency of urination","codes":{"SNOMED-CT":["162116003"]}}, + "165084003": {"description":"Clinical finding","codes":{"SNOMED-CT":["165084003"]}}, + "168734001": {"description":"Nonspecific abnormal findings on radiological and other examination of musculoskeletal system","codes":{"SNOMED-CT":["168734001"]}}, + "168750009": {"description":"Abnormal mammogram, unspecified","codes":{"SNOMED-CT":["168750009"]}}, + "170539009": {"description":"Poliomyelitis vaccination","codes":{"SNOMED-CT":["170539009"]}}, + "185086009": {"description":"Emphysematous bronchitis","codes":{"SNOMED-CT":["185086009"]}}, + "185903001": {"description":"Needs influenza immunization","codes":{"SNOMED-CT":["185903001"]}}, + "188155002": {"description":"Primary malignant neoplasm of lower outer quadrant of female breast","codes":{"SNOMED-CT":["188155002"]}}, + "189336000": {"description":"Carcinoma in situ of breast","codes":{"SNOMED-CT":["189336000"]}}, + "190828008": {"description":"Articular gout","codes":{"SNOMED-CT":["190828008"]}}, + "191519005": {"description":"Dementia associated with another disease","codes":{"SNOMED-CT":["191519005"]}}, + "193462001": {"description":"Insomnia","codes":{"SNOMED-CT":["193462001"]}}, + "193570009": {"description":"Asthma","codes":{"SNOMED-CT":["193570009"]}}, + "193589009": {"description":"Senile nuclear sclerosis","codes":{"SNOMED-CT":["193589009"]}}, + "193590000": {"description":"Mature cataract","codes":{"SNOMED-CT":["193590000"]}}, + "193995004": {"description":"Acquired stenosis of nasolacrimal duct","codes":{"SNOMED-CT":["193995004"]}}, + "194828000": {"description":"Angina","codes":{"SNOMED-CT":["194828000"]}}, + "195949008": {"description":"Chronic asthmatic bronchitis","codes":{"SNOMED-CT":["195949008"]}}, + "195967001": {"description":"Asthma","codes":{"SNOMED-CT":["195967001"]}}, + "197275004": {"description":"Subacute hepatic failure","codes":{"SNOMED-CT":["197275004"]}}, + "197321007": {"description":"Chronic nonalcoholic liver disease","codes":{"SNOMED-CT":["197321007"]}}, + "198036002": {"description":"Impotence of organic origin","codes":{"SNOMED-CT":["198036002"]}}, + "200645004": {"description":"Cellulitis and abscess of face","codes":{"SNOMED-CT":["200645004"]}}, + "200665006": {"description":"Cellulitis and abscess of upper arm and forearm","codes":{"SNOMED-CT":["200665006"]}}, + "201836008": {"description":"Localized, primary osteoarthritis of the lower leg","codes":{"SNOMED-CT":["201836008"]}}, + "202381003": {"description":"Joint effusion of the lower leg","codes":{"SNOMED-CT":["202381003"]}}, + "202708005": {"description":"Displacement of lumbar intervertebral disc without myelopathy","codes":{"SNOMED-CT":["202708005"]}}, + "202855006": {"description":"Lateral epicondylitis","codes":{"SNOMED-CT":["202855006"]}}, + "206200000": {"description":"Scalp injury","codes":{"SNOMED-CT":["206200000"]}}, + "209565008": {"description":"Lumbar sprain","codes":{"SNOMED-CT":["209565008"]}}, + "210682000": {"description":"Open wound of knee, leg [except thigh], and ankle, without mention of complication","codes":{"SNOMED-CT":["210682000"]}}, + "213299007": {"description":"Postprocedural state finding","codes":{"SNOMED-CT":["213299007"]}}, + "218218000": {"description":"Overexertion and strenuous movements","codes":{"SNOMED-CT":["218218000"]}}, + "230462002": {"description":"Migraine with aura","codes":{"SNOMED-CT":["230462002"]}}, + "233604007": {"description":"Pneumonia","codes":{"SNOMED-CT":["233604007"]}}, + "233817007": {"description":"Coronary arteriosclerosis","codes":{"SNOMED-CT":["233817007"]}}, + "235595009": {"description":"Gastroesophageal reflux disease","codes":{"SNOMED-CT":["235595009"]}}, + "235651006": {"description":"Gastritis","codes":{"SNOMED-CT":["235651006"]}}, + "236104004": {"description":"Other specified disorders of stomach and duodenum","codes":{"SNOMED-CT":["236104004"]}}, + "237084006": {"description":"Cervicitis and endocervicitis","codes":{"SNOMED-CT":["237084006"]}}, + "237602007": {"description":"Metabolic syndrome X","codes":{"SNOMED-CT":["237602007"]}}, + "238136002": {"description":"Morbid obesity","codes":{"SNOMED-CT":["238136002"]}}, + "239160006": {"description":"Hemorrhage AND/OR hematoma complicating procedure","codes":{"SNOMED-CT":["239160006"]}}, + "239872002": {"description":"Osteoarthrosis, unspecified whether generalized or localized, involving pelvic region and thigh","codes":{"SNOMED-CT":["239872002"]}}, + "239873007": {"description":"Osteoarthrosis, unspecified whether generalized or localized, involving lower leg","codes":{"SNOMED-CT":["239873007"]}}, + "240532009": {"description":"Human papilloma virus infection","codes":{"SNOMED-CT":["240532009"]}}, + "242489002": {"description":"Accidental burning caused by caustic and corrosive substance","codes":{"SNOMED-CT":["242489002"]}}, + "247373008": {"description":"Arthralgia of the ankle and/or foot","codes":{"SNOMED-CT":["247373008"]}}, + "248802009": {"description":"Disorder of breast","codes":{"SNOMED-CT":["248802009"]}}, + "249288007": {"description":"Incomplete emptying of bladder","codes":{"SNOMED-CT":["249288007"]}}, + "252030006": {"description":"Urinary symptoms","codes":{"SNOMED-CT":["252030006"]}}, + "254837009": {"description":"Primary malignant neoplasm of female breast","codes":{"SNOMED-CT":["254837009"]}}, + "254902007": {"description":"Benign prostatic hyperplasia","codes":{"SNOMED-CT":["254902007"]}}, + "266569009": {"description":"Benign prostatic hypertrophy","codes":{"SNOMED-CT":["266569009"]}}, + "266599000": {"description":"Dysmenorrhea","codes":{"SNOMED-CT":["266599000"]}}, + "266998003": {"description":"H/O: peptic ulcer","codes":{"SNOMED-CT":["266998003"]}}, + "267024001": {"description":"Abnormal weight loss","codes":{"SNOMED-CT":["267024001"]}}, + "267036007": {"description":"Other dyspnea and respiratory abnormality","codes":{"SNOMED-CT":["267036007"]}}, + "267038008": {"description":"Edema","codes":{"SNOMED-CT":["267038008"]}}, + "267431006": {"description":"Disorder of lipid metabolism","codes":{"SNOMED-CT":["267431006"]}}, + "267432004": {"description":"Pure hypercholesterolemia","codes":{"SNOMED-CT":["267432004"]}}, + "267782008": {"description":"Cellulitis and abscess of leg","codes":{"SNOMED-CT":["267782008"]}}, + "267952008": {"description":"Arthralgia of the pelvic region and thigh","codes":{"SNOMED-CT":["267952008"]}}, + "267953003": {"description":"Joint pain, lower leg","codes":{"SNOMED-CT":["267953003"]}}, + "271737000": {"description":"Anemia","codes":{"SNOMED-CT":["271737000"]}}, + "271807003": {"description":"Eruption","codes":{"SNOMED-CT":["271807003"]}}, + "271939006": {"description":"Leukorrhea","codes":{"SNOMED-CT":["271939006"]}}, + "272036004": {"description":"Asthenia","codes":{"SNOMED-CT":["272036004"]}}, + "274667000": {"description":"Finding of head and neck region","codes":{"SNOMED-CT":["274667000"]}}, + "275134007": {"description":"FH: Arthritis","codes":{"SNOMED-CT":["275134007"]}}, + "277843001": {"description":"Disruptive behavior disorder","codes":{"SNOMED-CT":["277843001"]}}, + "279039007": {"description":"Low back pain","codes":{"SNOMED-CT":["279039007"]}}, + "281239006": {"description":"Exacerbation of asthma","codes":{"SNOMED-CT":["281239006"]}}, + "285381006": {"description":"Acute exacerbation of chronic obstructive airways disease","codes":{"SNOMED-CT":["285381006"]}}, + "285836003": {"description":"Cervical intraepithelial neoplasia grade 1","codes":{"SNOMED-CT":["285836003"]}}, + "289903006": {"description":"Menopausal syndrome","codes":{"SNOMED-CT":["289903006"]}}, + "297242006": {"description":"FH: Cardiac disorder","codes":{"SNOMED-CT":["297242006"]}}, + "297946004": {"description":"Mononeuritis of lower limb","codes":{"SNOMED-CT":["297946004"]}}, + "299703001": {"description":"Swelling, mass, or lump in head and neck","codes":{"SNOMED-CT":["299703001"]}}, + "301717006": {"description":"Right upper quadrant pain","codes":{"SNOMED-CT":["301717006"]}}, + "302227002": {"description":"Disorder of cardiovascular system","codes":{"SNOMED-CT":["302227002"]}}, + "309523001": {"description":"H/O: artificial eye lens","codes":{"SNOMED-CT":["309523001"]}}, + "310249008": {"description":"Follow-up encounter","codes":{"SNOMED-CT":["310249008"]}}, + "312399001": {"description":"Abnormal results of thyroid function studies","codes":{"SNOMED-CT":["312399001"]}}, + "312824007": {"description":"Family history of malignant neoplasm of gastrointestinal tract","codes":{"SNOMED-CT":["312824007"]}}, + "312894000": {"description":"Disorder of bone and articular cartilage","codes":{"SNOMED-CT":["312894000"]}}, + "353295004": {"description":"Toxic diffuse goiter","codes":{"SNOMED-CT":["353295004"]}}, + "356744012": {"description":"Thiopurine methyltransferase deficiency","codes":{"SNOMED-CT":["356744012"]}}, + "363518003": {"description":"Primary malignant neoplasm of kidney","codes":{"SNOMED-CT":["363518003"]}}, + "372064008": {"description":"Primary malignant neoplasm of central portion of female breast","codes":{"SNOMED-CT":["372064008"]}}, + "386661006": {"description":"Fever","codes":{"SNOMED-CT":["386661006"]}}, + "386692008": {"description":"Excessive and frequent menstruation","codes":{"SNOMED-CT":["386692008"]}}, + "387712008": {"description":"Neonatal jaundice","codes":{"SNOMED-CT":["387712008"]}}, + "390951007": {"description":"Impaired fasting glucose","codes":{"SNOMED-CT":["390951007"]}}, + "395704004": {"description":"Heart disease","codes":{"SNOMED-CT":["395704004"]}}, + "396275006": {"description":"Osteoarthritis","codes":{"SNOMED-CT":["396275006"]}}, + "398050005": {"description":"Diverticular disease of colon","codes":{"SNOMED-CT":["398050005"]}}, + "398909004": {"description":"Rosacea","codes":{"SNOMED-CT":["398909004"]}}, + "399068003": {"description":"Primary malignant neoplasm of prostate","codes":{"SNOMED-CT":["399068003"]}}, + "399935008": {"description":"high blood glucose","codes":{"SNOMED-CT":["399935008"]}}, + "399963005": {"description":"Abrasion or friction burn of other, multiple, and unspecified sites, without mention of infection","codes":{"SNOMED-CT":["399963005"]}}, + "400210000": {"description":"Hemangioma","codes":{"SNOMED-CT":["400210000"]}}, + "404640003": {"description":"Dizzyness","codes":{"SNOMED-CT":["404640003"]}}, + "405729008": {"description":"Blood in stool","codes":{"SNOMED-CT":["405729008"]}}, + "405737000": {"description":"Acute phargygitis","codes":{"SNOMED-CT":["405737000"]}}, + "406506008": {"description":"Attention deficit hyperactivity disorder","codes":{"SNOMED-CT":["406506008"]}}, + "408643008": {"description":"Overlapping malignant neoplasm of female breast","codes":{"SNOMED-CT":["408643008"]}}, + "409622000": {"description":"Acute respiratory failure","codes":{"SNOMED-CT":["409622000"]}}, + "413838009": {"description":"Chronic ischemic heart disease","codes":{"SNOMED-CT":["413838009"]}}, + "414795007": {"description":"Chronic ischemic heart disease","codes":{"SNOMED-CT":["414795007"]}}, + "414941008": {"description":"Onychomycosis due to dermatophyte","codes":{"SNOMED-CT":["414941008"]}}, + "415081006": {"description":"History of malignant neoplasm of kidney","codes":{"SNOMED-CT":["415081006"]}}, + "421961002": {"description":"Allergy","codes":{"SNOMED-CT":["421961002"]}}, + "422400008": {"description":"Vomiting","codes":{"SNOMED-CT":["422400008"]}}, + "422587007": {"description":"Nausea","codes":{"SNOMED-CT":["422587007"]}}, + "425229001": {"description":"foreign body in larynx","codes":{"SNOMED-CT":["425229001"]}}, + "427359005": {"description":"Disorder of lung","codes":{"SNOMED-CT":["427359005"]}}, + "428007007": {"description":"Erectile dysfunction","codes":{"SNOMED-CT":["428007007"]}}, + "429047008": {"description":"History of polyp of colon","codes":{"SNOMED-CT":["429047008"]}}, + "429305003": {"description":"Insect bite, nonvenomous, of other, multiple, and unspecified sites, without mention of infection","codes":{"SNOMED-CT":["429305003"]}}, + "429484003": {"description":"History of malignant neoplasm of cervix","codes":{"SNOMED-CT":["429484003"]}}, + "429494008": {"description":"Seroma complicating a procedure","codes":{"SNOMED-CT":["429494008"]}}, + "429998004": {"description":"Vascular dementia, uncomplicated","codes":{"SNOMED-CT":["429998004"]}}, + "431737008": {"description":"UTI","codes":{"SNOMED-CT":["431737008"]}}, + "441087007": {"description":"Papanicolaou smear of cervix with atypical squamous cells of undetermined significance (ASC-US)","codes":{"SNOMED-CT":["441087007"]}}, + "442311008": {"description":"Single liveborn, born in hospital, delivered without mention of cesarean section","codes":{"SNOMED-CT":["442311008"]}}, + "443092002": {"description":"Exostosis of unspecified site","codes":{"SNOMED-CT":["443092002"]}}, + "448952004": {"description":"Infiltrating duct carcinoma of female breast","codes":{"SNOMED-CT":["448952004"]}} + } + }, + + // Records per page + patientsPerPage: 10, + + // AJAX requests timeout (ms) + timeout: 20000, + + // Only the selected patients are rendered. Should be false or the + // preselected patient IDs should be passed to the window. Otherwise + // It will result in rendering no patients at all. + renderSelectedOnly: false, + + // If enabled is true (then url and param MUST be set) then clicking on the + // patient-related resources in detail view will open their source in that + // external viewer. Otherwise they will just be opened in new browser tab. + fhirViewer: { + enabled: true, + url : "http://docs.smarthealthit.org/fhir-viewer/index.html", + param : "url" + }, + + // What to send when the OK dialog button is clicked. Possible values: + // "id-list" - comma-separated list of patient IDs (default) + // "id-array" - array of patient IDs + // "patients" - array of patient JSON objects + outputMode: "id-list", + + // "automatic" -> onChange plus defer in some cases + // "manual" -> render a submit button + submitStrategy: "manual", + + // There are no tags above so the tag selector will not offer auto suggestions. + // However, this server does not have any tags in it's data so any filtering + // by tag will fail and that is why we want to simply hide the Tags tab. + // hideTagSelector: true +} \ No newline at end of file diff --git a/build/config/stu3-open-hapi.json5 b/build/config/stu3-open-hapi.json5 new file mode 100644 index 0000000..5a0c924 --- /dev/null +++ b/build/config/stu3-open-hapi.json5 @@ -0,0 +1,49 @@ +{ + server: { + // "DSTU-2" or "STU-3" + type: "STU-3", + + // Api URL + url: "http://fhirtest.uhn.ca/baseDstu3", + + // The pre-populated tags in the tag selector + tags: [ + // we don't know this yet + ], + + // The mediacal conditions available on this server + conditions: { + // we don't know this yet + } + }, + + // Records per page + patientsPerPage: 10, + + // AJAX requests timeout (ms) + timeout: 20000, + + // Only the selected patients are rendered. Should be false or the + // preselected patient IDs should be passed to the window. Otherwise + // It will result in rendering no patients at all. + renderSelectedOnly: false, + + // If enabled is true (then url and param MUST be set) then clicking on the + // patient-related resources in detail view will open their source in that + // external viewer. Otherwise they will just be opened in new browser tab. + fhirViewer: { + enabled: true, + url : "http://docs.smarthealthit.org/fhir-viewer/index.html", + param : "url" + }, + + // What to send when the OK dialog button is clicked. Possible values: + // "id-list" - comma-separated list of patient IDs (default) + // "id-array" - array of patient IDs + // "patients" - array of patient JSON objects + outputMode: "id-list", + + // "automatic" -> onChange plus defer in some cases + // "manual" -> render a submit button + submitStrategy: "manual" +} \ No newline at end of file diff --git a/build/config/stu3-open-sandbox.json5 b/build/config/stu3-open-sandbox.json5 new file mode 100644 index 0000000..366e2f9 --- /dev/null +++ b/build/config/stu3-open-sandbox.json5 @@ -0,0 +1,371 @@ +{ + server: { + // "DSTU-2" or "STU-3" + type: "STU-3", + + // Api URL + url: "https://sb-fhir-stu3.smarthealthit.org/smartstu3/open", + + // The pre-populated tags in the tag selector + tags: [ + { + key : "pro-7-2017", + label: "PROm sample patients 7/2017" + }, + { + key : "smart-7-2017", + label: "SMART sample patients 7/2017" + }, + { + key : "synthea-7-2017", + label: "Synthea sample patients 7/2017" + } + ], + + // The mediacal conditions available on this server + conditions: { + "568005": {"description":"Tic disorder","codes":{"SNOMED-CT":["568005"]}}, + "1023001": {"description":"Apnea","codes":{"SNOMED-CT":["1023001"]}}, + "1201005": {"description":"Benign essential hypertension","codes":{"SNOMED-CT":["1201005"]}}, + "1475003": {"description":"Herpes simplex without complication","codes":{"SNOMED-CT":["1475003"]}}, + "1755008": {"description":"Old myocardial infarction","codes":{"SNOMED-CT":["1755008"]}}, + "3723001": {"description":"Arthropathy","codes":{"SNOMED-CT":["3723001"]}}, + "4557003": {"description":"Preinfarction syndrome","codes":{"SNOMED-CT":["4557003"]}}, + "4800001": {"description":"Urine finding","codes":{"SNOMED-CT":["4800001"]}}, + "5935008": {"description":"Contraceptive pill surveillance","codes":{"SNOMED-CT":["5935008"]}}, + "7620006": {"description":"Crohn's disease of large bowel","codes":{"SNOMED-CT":["7620006"]}}, + "8517006": {"description":"History of tobacco use","codes":{"SNOMED-CT":["8517006"]}}, + "8722008": {"description":"Aortic insufficiency","codes":{"SNOMED-CT":["8722008"]}}, + "9826008": {"description":"Conjunctivitis","codes":{"SNOMED-CT":["9826008"]}}, + "10509002": {"description":"Acute bronchitis","codes":{"SNOMED-CT":["10509002"]}}, + "10601006": {"description":"Pain in limb","codes":{"SNOMED-CT":["10601006"]}}, + "11934000": {"description":"Hypertensive disorder","codes":{"SNOMED-CT":["11934000"]}}, + "12063002": {"description":"Hemorrhage of rectum and anus","codes":{"SNOMED-CT":["12063002"]}}, + "13200003": {"description":"Peptic ulcer without hemorrhage, without perforation AND without obstruction","codes":{"SNOMED-CT":["13200003"]}}, + "13600006": {"description":"Hair and hair follicle diseases","codes":{"SNOMED-CT":["13600006"]}}, + "13645005": {"description":"Chronic obstructive lung disease","codes":{"SNOMED-CT":["13645005"]}}, + "14302001": {"description":"Absence of menstruation","codes":{"SNOMED-CT":["14302001"]}}, + "14760008": {"description":"Constipation","codes":{"SNOMED-CT":["14760008"]}}, + "15639000": {"description":"Single major depressive episode, moderate","codes":{"SNOMED-CT":["15639000"]}}, + "15805002": {"description":"Acute sinusitis","codes":{"SNOMED-CT":["15805002"]}}, + "16001004": {"description":"Otalgia","codes":{"SNOMED-CT":["16001004"]}}, + "16041008": {"description":"Ovarian failure","codes":{"SNOMED-CT":["16041008"]}}, + "16932000": {"description":"Nausea and vomiting","codes":{"SNOMED-CT":["16932000"]}}, + "17226007": {"description":"Adjustment disorder","codes":{"SNOMED-CT":["17226007"]}}, + "18165001": {"description":"Jaundice","codes":{"SNOMED-CT":["18165001"]}}, + "18818009": {"description":"Recurrent major depressive episodes, moderate","codes":{"SNOMED-CT":["18818009"]}}, + "18941000": {"description":"Oppositional defiant disorder","codes":{"SNOMED-CT":["18941000"]}}, + "19471005": {"description":"Lymphadenitis","codes":{"SNOMED-CT":["19471005"]}}, + "20301004": {"description":"Disturbance in speech","codes":{"SNOMED-CT":["20301004"]}}, + "21186006": {"description":"Chronic non-suppurative otitis media","codes":{"SNOMED-CT":["21186006"]}}, + "21522001": {"description":"Abdominal pain","codes":{"SNOMED-CT":["21522001"]}}, + "22298006": {"description":"Acute myocardial infarction","codes":{"SNOMED-CT":["22298006"]}}, + "23560001": {"description":"Other specified pervasive developmental disorders, current or active state","codes":{"SNOMED-CT":["23560001"]}}, + "23913003": {"description":"External hemorrhoids without complication","codes":{"SNOMED-CT":["23913003"]}}, + "24199005": {"description":"Clinical finding","codes":{"SNOMED-CT":["24199005"]}}, + "25064002": {"description":"Headache","codes":{"SNOMED-CT":["25064002"]}}, + "25374005": {"description":"Other and unspecified noninfectious gastroenteritis and colitis","codes":{"SNOMED-CT":["25374005"]}}, + "25569003": {"description":"Paroxysmal ventricular tachycardia","codes":{"SNOMED-CT":["25569003"]}}, + "26079004": {"description":"Abnormal involuntary movement","codes":{"SNOMED-CT":["26079004"]}}, + "26929004": {"description":"Alzheimer's disease","codes":{"SNOMED-CT":["26929004"]}}, + "28651003": {"description":"Orthostatic hypotension","codes":{"SNOMED-CT":["28651003"]}}, + "29857009": {"description":"Chest pain","codes":{"SNOMED-CT":["29857009"]}}, + "30473006": {"description":"Abdominal pain","codes":{"SNOMED-CT":["30473006"]}}, + "30800001": {"description":"Acute Vaginitis","codes":{"SNOMED-CT":["30800001"]}}, + "30989003": {"description":"Arthralgia of the lower leg","codes":{"SNOMED-CT":["30989003"]}}, + "31822004": {"description":"Urethritis","codes":{"SNOMED-CT":["31822004"]}}, + "32398004": {"description":"Bronchitis","codes":{"SNOMED-CT":["32398004"]}}, + "34014006": {"description":"Viral disease","codes":{"SNOMED-CT":["34014006"]}}, + "34095006": {"description":"Dehydration","codes":{"SNOMED-CT":["34095006"]}}, + "34486009": {"description":"Thyrotoxicosis without goiter OR other cause","codes":{"SNOMED-CT":["34486009"]}}, + "34649000": {"description":"Closed fracture of malar AND/OR maxillary bones","codes":{"SNOMED-CT":["34649000"]}}, + "34713006": {"description":"Vitamin D deficiency","codes":{"SNOMED-CT":["34713006"]}}, + "34840004": {"description":"Enthesopathy","codes":{"SNOMED-CT":["34840004"]}}, + "35183001": {"description":"Acute secretory otitis media","codes":{"SNOMED-CT":["35183001"]}}, + "35253001": {"description":"Child attention deficit disorder","codes":{"SNOMED-CT":["35253001"]}}, + "35489007": {"description":"Depressive disorder","codes":{"SNOMED-CT":["35489007"]}}, + "35678005": {"description":"Multiple joint pain","codes":{"SNOMED-CT":["35678005"]}}, + "36083008": {"description":"Sinus node dysfunction","codes":{"SNOMED-CT":["36083008"]}}, + "36971009": {"description":"Acute sinusitis","codes":{"SNOMED-CT":["36971009"]}}, + "37796009": {"description":"Migraine","codes":{"SNOMED-CT":["37796009"]}}, + "38341003": {"description":"Essential hypertension","codes":{"SNOMED-CT":["38341003"]}}, + "39772007": {"description":"Anal and rectal polyp","codes":{"SNOMED-CT":["39772007"]}}, + "39812007": {"description":"Contusion of forearm","codes":{"SNOMED-CT":["39812007"]}}, + "40055000": {"description":"Chronic sinusitis","codes":{"SNOMED-CT":["40055000"]}}, + "40930008": {"description":"Hypothyroidism","codes":{"SNOMED-CT":["40930008"]}}, + "41006004": {"description":"Depression","codes":{"SNOMED-CT":["41006004"]}}, + "42343007": {"description":"Congestive heart failure","codes":{"SNOMED-CT":["42343007"]}}, + "43116000": {"description":"Contact dermatitis and other eczema, unspecified cause","codes":{"SNOMED-CT":["43116000"]}}, + "43339004": {"description":"Hypokalemia","codes":{"SNOMED-CT":["43339004"]}}, + "43878008": {"description":"Streptococcal sore throat","codes":{"SNOMED-CT":["43878008"]}}, + "44054006": {"description":"Diabetes mellitus type 2","codes":{"SNOMED-CT":["44054006"]}}, + "44465007": {"description":"Sprain of ankle","codes":{"SNOMED-CT":["44465007"]}}, + "44808001": {"description":"Conduction disorder of the heart","codes":{"SNOMED-CT":["44808001"]}}, + "45326000": {"description":"Shoulder joint pain","codes":{"SNOMED-CT":["45326000"]}}, + "46635009": {"description":"Diabetes mellitus type 1","codes":{"SNOMED-CT":["46635009"]}}, + "46689006": {"description":"Hypertrophy of tonsils","codes":{"SNOMED-CT":["46689006"]}}, + "46775006": {"description":"Respiratory distress syndrome in the newborn","codes":{"SNOMED-CT":["46775006"]}}, + "48167000": {"description":"Amnesia","codes":{"SNOMED-CT":["48167000"]}}, + "48694002": {"description":"Anxiety","codes":{"SNOMED-CT":["48694002"]}}, + "49436004": {"description":"Atrial fibrillation","codes":{"SNOMED-CT":["49436004"]}}, + "49727002": {"description":"Cough","codes":{"SNOMED-CT":["49727002"]}}, + "50438001": {"description":"Peripheral vertigo","codes":{"SNOMED-CT":["50438001"]}}, + "50485007": {"description":"Low tension glaucoma","codes":{"SNOMED-CT":["50485007"]}}, + "51868009": {"description":"Duodenal ulcer without hemorrhage, without perforation AND without obstruction","codes":{"SNOMED-CT":["51868009"]}}, + "52073004": {"description":"Unspecified disorders of menstruation and other abnormal bleeding from female genital tract","codes":{"SNOMED-CT":["52073004"]}}, + "52448006": {"description":"Other persistent mental disorders due to conditions classified elsewhere","codes":{"SNOMED-CT":["52448006"]}}, + "53298000": {"description":"Hematuria syndrome","codes":{"SNOMED-CT":["53298000"]}}, + "53726008": {"description":"Acute conjunctivitis","codes":{"SNOMED-CT":["53726008"]}}, + "53741008": {"description":"Coronary arteriosclerosis","codes":{"SNOMED-CT":["53741008"]}}, + "54150009": {"description":"Acute upper respiratory infection","codes":{"SNOMED-CT":["54150009"]}}, + "54302000": {"description":"Disorder of breast","codes":{"SNOMED-CT":["54302000"]}}, + "55260003": {"description":"Calcaneal spur","codes":{"SNOMED-CT":["55260003"]}}, + "55525008": {"description":"Paralytic ileus","codes":{"SNOMED-CT":["55525008"]}}, + "55566008": {"description":"Accident","codes":{"SNOMED-CT":["55566008"]}}, + "55822004": {"description":"Hyperlipidemia","codes":{"SNOMED-CT":["55822004"]}}, + "56018004": {"description":"Wheezing","codes":{"SNOMED-CT":["56018004"]}}, + "56097005": {"description":"Migraine without aura","codes":{"SNOMED-CT":["56097005"]}}, + "56727007": {"description":"Vitiligo","codes":{"SNOMED-CT":["56727007"]}}, + "57643001": {"description":"Gastro-esophageal reflux disease with esophagitis","codes":{"SNOMED-CT":["57643001"]}}, + "58075000": {"description":"Contusion of toe","codes":{"SNOMED-CT":["58075000"]}}, + "59455009": {"description":"Acidosis","codes":{"SNOMED-CT":["59455009"]}}, + "60241006": {"description":"Female stress incontinence","codes":{"SNOMED-CT":["60241006"]}}, + "60700002": {"description":"Sensorineural hearing loss","codes":{"SNOMED-CT":["60700002"]}}, + "61582004": {"description":"Allergic rhinitis","codes":{"SNOMED-CT":["61582004"]}}, + "62315008": {"description":"Diarrhea","codes":{"SNOMED-CT":["62315008"]}}, + "63102001": {"description":"Visual disturbance","codes":{"SNOMED-CT":["63102001"]}}, + "64715009": {"description":"Hypertensive heart disease, unspecified, without heart failure","codes":{"SNOMED-CT":["64715009"]}}, + "64859006": {"description":"Osteoporosis","codes":{"SNOMED-CT":["64859006"]}}, + "65074000": {"description":"Chronic obstructive lung disease","codes":{"SNOMED-CT":["65074000"]}}, + "65363002": {"description":"otitis media","codes":{"SNOMED-CT":["65363002"]}}, + "66857006": {"description":"Hemoptysis","codes":{"SNOMED-CT":["66857006"]}}, + "67678004": {"description":"Acute atopic conjunctivitis","codes":{"SNOMED-CT":["67678004"]}}, + "68225006": {"description":"Alopecia Areata","codes":{"SNOMED-CT":["68225006"]}}, + "68235000": {"description":"Other diseases of nasal cavity and sinuses","codes":{"SNOMED-CT":["68235000"]}}, + "68272006": {"description":"Acute maxillary sinusitis","codes":{"SNOMED-CT":["68272006"]}}, + "68496003": {"description":"Benign neoplasm of colon","codes":{"SNOMED-CT":["68496003"]}}, + "68566005": {"description":"Urinary tract infectious disease","codes":{"SNOMED-CT":["68566005"]}}, + "68962001": {"description":"Myalgia and myositis, unspecified","codes":{"SNOMED-CT":["68962001"]}}, + "70153002": {"description":"Hemorrhoids","codes":{"SNOMED-CT":["70153002"]}}, + "71186008": {"description":"Croup","codes":{"SNOMED-CT":["71186008"]}}, + "71884009": {"description":"Precordial pain","codes":{"SNOMED-CT":["71884009"]}}, + "72552008": {"description":"Feeding problems in newborn","codes":{"SNOMED-CT":["72552008"]}}, + "73749009": {"description":"Neonatal jaundice associated with preterm delivery","codes":{"SNOMED-CT":["73749009"]}}, + "76376003": {"description":"Endometriosis of uterus","codes":{"SNOMED-CT":["76376003"]}}, + "76498008": {"description":"Asymptomatic postmenopausal status (age-related) (natural)","codes":{"SNOMED-CT":["76498008"]}}, + "77075001": {"description":"Primary open angle glaucoma","codes":{"SNOMED-CT":["77075001"]}}, + "78164000": {"description":"Feeding difficulties and mismanagement","codes":{"SNOMED-CT":["78164000"]}}, + "78267003": {"description":"Cocaine abuse","codes":{"SNOMED-CT":["78267003"]}}, + "78275009": {"description":"Unspecified sleep apnea","codes":{"SNOMED-CT":["78275009"]}}, + "78667006": {"description":"Dysthymia","codes":{"SNOMED-CT":["78667006"]}}, + "78737005": {"description":"Acute frontal sinusitis","codes":{"SNOMED-CT":["78737005"]}}, + "78809005": {"description":"Benign neoplasm of stomach","codes":{"SNOMED-CT":["78809005"]}}, + "79298009": {"description":"Single major depressive episode, mild","codes":{"SNOMED-CT":["79298009"]}}, + "79890006": {"description":"Anorexia","codes":{"SNOMED-CT":["79890006"]}}, + "79922009": {"description":"Epigastric pain","codes":{"SNOMED-CT":["79922009"]}}, + "80182007": {"description":"Irregular periods","codes":{"SNOMED-CT":["80182007"]}}, + "80394007": {"description":"Blood chemistry abnormal","codes":{"SNOMED-CT":["80394007"]}}, + "81302005": {"description":"General well-being finding","codes":{"SNOMED-CT":["81302005"]}}, + "81564005": {"description":"Chronic serous otitis media","codes":{"SNOMED-CT":["81564005"]}}, + "81680005": {"description":"Neck pain","codes":{"SNOMED-CT":["81680005"]}}, + "82271004": {"description":"Injury of head","codes":{"SNOMED-CT":["82271004"]}}, + "82272006": {"description":"Common cold","codes":{"SNOMED-CT":["82272006"]}}, + "82523003": {"description":"Congestive rheumatic heart failure","codes":{"SNOMED-CT":["82523003"]}}, + "83521008": {"description":"Dilated cardiomyopathy secondary to alcohol","codes":{"SNOMED-CT":["83521008"]}}, + "84027009": {"description":"Pernicious anemia","codes":{"SNOMED-CT":["84027009"]}}, + "84229001": {"description":"Malaise and fatigue","codes":{"SNOMED-CT":["84229001"]}}, + "85005007": {"description":"Cannabis dependence","codes":{"SNOMED-CT":["85005007"]}}, + "85848002": {"description":"Superficial injury of cornea","codes":{"SNOMED-CT":["85848002"]}}, + "85898001": {"description":"Cardiomyopathy","codes":{"SNOMED-CT":["85898001"]}}, + "87433001": {"description":"Pulmonary emphysema","codes":{"SNOMED-CT":["87433001"]}}, + "87522002": {"description":"Iron deficiency anemia","codes":{"SNOMED-CT":["87522002"]}}, + "88223008": {"description":"Chronic pulmonary heart disease","codes":{"SNOMED-CT":["88223008"]}}, + "88616000": {"description":"Acne","codes":{"SNOMED-CT":["88616000"]}}, + "89164003": {"description":"Breast lump","codes":{"SNOMED-CT":["89164003"]}}, + "89765005": {"description":"Tobacco use disorder","codes":{"SNOMED-CT":["89765005"]}}, + "90392009": {"description":"Spasm","codes":{"SNOMED-CT":["90392009"]}}, + "90458007": {"description":"Internal hemorrhoids without mention of complication","codes":{"SNOMED-CT":["90458007"]}}, + "90560007": {"description":"Gout","codes":{"SNOMED-CT":["90560007"]}}, + "90708001": {"description":"Unspecified disorder of kidney and ureter","codes":{"SNOMED-CT":["90708001"]}}, + "91175000": {"description":"Seizure","codes":{"SNOMED-CT":["91175000"]}}, + "91487003": {"description":"Diaper rash","codes":{"SNOMED-CT":["91487003"]}}, + "92359006": {"description":"Benign neoplasm of skin of face","codes":{"SNOMED-CT":["92359006"]}}, + "92380000": {"description":"Benign neoplasm of skin of trunk","codes":{"SNOMED-CT":["92380000"]}}, + "93616000": {"description":"Intramural leiomyoma of uterus","codes":{"SNOMED-CT":["93616000"]}}, + "95280005": {"description":"Subserous leiomyoma of uterus","codes":{"SNOMED-CT":["95280005"]}}, + "95617006": {"description":"Perinatal cyanotic attacks","codes":{"SNOMED-CT":["95617006"]}}, + "102588006": {"description":"Painful respiration","codes":{"SNOMED-CT":["102588006"]}}, + "102589003": {"description":"Chest pain","codes":{"SNOMED-CT":["102589003"]}}, + "102594003": {"description":"Abnormal ECG","codes":{"SNOMED-CT":["102594003"]}}, + "108365000": {"description":"Unspecified local infection of skin and subcutaneous tissue","codes":{"SNOMED-CT":["108365000"]}}, + "111583006": {"description":"White blood cell disorder","codes":{"SNOMED-CT":["111583006"]}}, + "125598003": {"description":"Other and unspecified injury to elbow, forearm, and wrist","codes":{"SNOMED-CT":["125598003"]}}, + "125649002": {"description":"Open wound of forearm without complication","codes":{"SNOMED-CT":["125649002"]}}, + "126926005": {"description":"Neoplasm of breast","codes":{"SNOMED-CT":["126926005"]}}, + "128045006": {"description":"Cellulitis and abscess of unspecified sites","codes":{"SNOMED-CT":["128045006"]}}, + "128302006": {"description":"Chronic hepatitis C","codes":{"SNOMED-CT":["128302006"]}}, + "160303001": {"description":"FH: Diabetes mellitus","codes":{"SNOMED-CT":["160303001"]}}, + "161591004": {"description":"H/O: penicillin allergy","codes":{"SNOMED-CT":["161591004"]}}, + "161891005": {"description":"Backache","codes":{"SNOMED-CT":["161891005"]}}, + "162031009": {"description":"Dyspepsia and other specified disorders of function of stomach","codes":{"SNOMED-CT":["162031009"]}}, + "162116003": {"description":"Finding of frequency of urination","codes":{"SNOMED-CT":["162116003"]}}, + "165084003": {"description":"Clinical finding","codes":{"SNOMED-CT":["165084003"]}}, + "168734001": {"description":"Nonspecific abnormal findings on radiological and other examination of musculoskeletal system","codes":{"SNOMED-CT":["168734001"]}}, + "168750009": {"description":"Abnormal mammogram, unspecified","codes":{"SNOMED-CT":["168750009"]}}, + "170539009": {"description":"Poliomyelitis vaccination","codes":{"SNOMED-CT":["170539009"]}}, + "185086009": {"description":"Emphysematous bronchitis","codes":{"SNOMED-CT":["185086009"]}}, + "185903001": {"description":"Needs influenza immunization","codes":{"SNOMED-CT":["185903001"]}}, + "188155002": {"description":"Primary malignant neoplasm of lower outer quadrant of female breast","codes":{"SNOMED-CT":["188155002"]}}, + "189336000": {"description":"Carcinoma in situ of breast","codes":{"SNOMED-CT":["189336000"]}}, + "190828008": {"description":"Articular gout","codes":{"SNOMED-CT":["190828008"]}}, + "191519005": {"description":"Dementia associated with another disease","codes":{"SNOMED-CT":["191519005"]}}, + "193462001": {"description":"Insomnia","codes":{"SNOMED-CT":["193462001"]}}, + "193570009": {"description":"Asthma","codes":{"SNOMED-CT":["193570009"]}}, + "193589009": {"description":"Senile nuclear sclerosis","codes":{"SNOMED-CT":["193589009"]}}, + "193590000": {"description":"Mature cataract","codes":{"SNOMED-CT":["193590000"]}}, + "193995004": {"description":"Acquired stenosis of nasolacrimal duct","codes":{"SNOMED-CT":["193995004"]}}, + "194828000": {"description":"Angina","codes":{"SNOMED-CT":["194828000"]}}, + "195949008": {"description":"Chronic asthmatic bronchitis","codes":{"SNOMED-CT":["195949008"]}}, + "195967001": {"description":"Asthma","codes":{"SNOMED-CT":["195967001"]}}, + "197275004": {"description":"Subacute hepatic failure","codes":{"SNOMED-CT":["197275004"]}}, + "197321007": {"description":"Chronic nonalcoholic liver disease","codes":{"SNOMED-CT":["197321007"]}}, + "198036002": {"description":"Impotence of organic origin","codes":{"SNOMED-CT":["198036002"]}}, + "200645004": {"description":"Cellulitis and abscess of face","codes":{"SNOMED-CT":["200645004"]}}, + "200665006": {"description":"Cellulitis and abscess of upper arm and forearm","codes":{"SNOMED-CT":["200665006"]}}, + "201836008": {"description":"Localized, primary osteoarthritis of the lower leg","codes":{"SNOMED-CT":["201836008"]}}, + "202381003": {"description":"Joint effusion of the lower leg","codes":{"SNOMED-CT":["202381003"]}}, + "202708005": {"description":"Displacement of lumbar intervertebral disc without myelopathy","codes":{"SNOMED-CT":["202708005"]}}, + "202855006": {"description":"Lateral epicondylitis","codes":{"SNOMED-CT":["202855006"]}}, + "206200000": {"description":"Scalp injury","codes":{"SNOMED-CT":["206200000"]}}, + "209565008": {"description":"Lumbar sprain","codes":{"SNOMED-CT":["209565008"]}}, + "210682000": {"description":"Open wound of knee, leg [except thigh], and ankle, without mention of complication","codes":{"SNOMED-CT":["210682000"]}}, + "213299007": {"description":"Postprocedural state finding","codes":{"SNOMED-CT":["213299007"]}}, + "218218000": {"description":"Overexertion and strenuous movements","codes":{"SNOMED-CT":["218218000"]}}, + "230462002": {"description":"Migraine with aura","codes":{"SNOMED-CT":["230462002"]}}, + "233604007": {"description":"Pneumonia","codes":{"SNOMED-CT":["233604007"]}}, + "233817007": {"description":"Coronary arteriosclerosis","codes":{"SNOMED-CT":["233817007"]}}, + "235595009": {"description":"Gastroesophageal reflux disease","codes":{"SNOMED-CT":["235595009"]}}, + "235651006": {"description":"Gastritis","codes":{"SNOMED-CT":["235651006"]}}, + "236104004": {"description":"Other specified disorders of stomach and duodenum","codes":{"SNOMED-CT":["236104004"]}}, + "237084006": {"description":"Cervicitis and endocervicitis","codes":{"SNOMED-CT":["237084006"]}}, + "237602007": {"description":"Metabolic syndrome X","codes":{"SNOMED-CT":["237602007"]}}, + "238136002": {"description":"Morbid obesity","codes":{"SNOMED-CT":["238136002"]}}, + "239160006": {"description":"Hemorrhage AND/OR hematoma complicating procedure","codes":{"SNOMED-CT":["239160006"]}}, + "239872002": {"description":"Osteoarthrosis, unspecified whether generalized or localized, involving pelvic region and thigh","codes":{"SNOMED-CT":["239872002"]}}, + "239873007": {"description":"Osteoarthrosis, unspecified whether generalized or localized, involving lower leg","codes":{"SNOMED-CT":["239873007"]}}, + "240532009": {"description":"Human papilloma virus infection","codes":{"SNOMED-CT":["240532009"]}}, + "242489002": {"description":"Accidental burning caused by caustic and corrosive substance","codes":{"SNOMED-CT":["242489002"]}}, + "247373008": {"description":"Arthralgia of the ankle and/or foot","codes":{"SNOMED-CT":["247373008"]}}, + "248802009": {"description":"Disorder of breast","codes":{"SNOMED-CT":["248802009"]}}, + "249288007": {"description":"Incomplete emptying of bladder","codes":{"SNOMED-CT":["249288007"]}}, + "252030006": {"description":"Urinary symptoms","codes":{"SNOMED-CT":["252030006"]}}, + "254837009": {"description":"Primary malignant neoplasm of female breast","codes":{"SNOMED-CT":["254837009"]}}, + "254902007": {"description":"Benign prostatic hyperplasia","codes":{"SNOMED-CT":["254902007"]}}, + "266569009": {"description":"Benign prostatic hypertrophy","codes":{"SNOMED-CT":["266569009"]}}, + "266599000": {"description":"Dysmenorrhea","codes":{"SNOMED-CT":["266599000"]}}, + "266998003": {"description":"H/O: peptic ulcer","codes":{"SNOMED-CT":["266998003"]}}, + "267024001": {"description":"Abnormal weight loss","codes":{"SNOMED-CT":["267024001"]}}, + "267036007": {"description":"Other dyspnea and respiratory abnormality","codes":{"SNOMED-CT":["267036007"]}}, + "267038008": {"description":"Edema","codes":{"SNOMED-CT":["267038008"]}}, + "267431006": {"description":"Disorder of lipid metabolism","codes":{"SNOMED-CT":["267431006"]}}, + "267432004": {"description":"Pure hypercholesterolemia","codes":{"SNOMED-CT":["267432004"]}}, + "267782008": {"description":"Cellulitis and abscess of leg","codes":{"SNOMED-CT":["267782008"]}}, + "267952008": {"description":"Arthralgia of the pelvic region and thigh","codes":{"SNOMED-CT":["267952008"]}}, + "267953003": {"description":"Joint pain, lower leg","codes":{"SNOMED-CT":["267953003"]}}, + "271737000": {"description":"Anemia","codes":{"SNOMED-CT":["271737000"]}}, + "271807003": {"description":"Eruption","codes":{"SNOMED-CT":["271807003"]}}, + "271939006": {"description":"Leukorrhea","codes":{"SNOMED-CT":["271939006"]}}, + "272036004": {"description":"Asthenia","codes":{"SNOMED-CT":["272036004"]}}, + "274667000": {"description":"Finding of head and neck region","codes":{"SNOMED-CT":["274667000"]}}, + "275134007": {"description":"FH: Arthritis","codes":{"SNOMED-CT":["275134007"]}}, + "277843001": {"description":"Disruptive behavior disorder","codes":{"SNOMED-CT":["277843001"]}}, + "279039007": {"description":"Low back pain","codes":{"SNOMED-CT":["279039007"]}}, + "281239006": {"description":"Exacerbation of asthma","codes":{"SNOMED-CT":["281239006"]}}, + "285381006": {"description":"Acute exacerbation of chronic obstructive airways disease","codes":{"SNOMED-CT":["285381006"]}}, + "285836003": {"description":"Cervical intraepithelial neoplasia grade 1","codes":{"SNOMED-CT":["285836003"]}}, + "289903006": {"description":"Menopausal syndrome","codes":{"SNOMED-CT":["289903006"]}}, + "297242006": {"description":"FH: Cardiac disorder","codes":{"SNOMED-CT":["297242006"]}}, + "297946004": {"description":"Mononeuritis of lower limb","codes":{"SNOMED-CT":["297946004"]}}, + "299703001": {"description":"Swelling, mass, or lump in head and neck","codes":{"SNOMED-CT":["299703001"]}}, + "301717006": {"description":"Right upper quadrant pain","codes":{"SNOMED-CT":["301717006"]}}, + "302227002": {"description":"Disorder of cardiovascular system","codes":{"SNOMED-CT":["302227002"]}}, + "309523001": {"description":"H/O: artificial eye lens","codes":{"SNOMED-CT":["309523001"]}}, + "310249008": {"description":"Follow-up encounter","codes":{"SNOMED-CT":["310249008"]}}, + "312399001": {"description":"Abnormal results of thyroid function studies","codes":{"SNOMED-CT":["312399001"]}}, + "312824007": {"description":"Family history of malignant neoplasm of gastrointestinal tract","codes":{"SNOMED-CT":["312824007"]}}, + "312894000": {"description":"Disorder of bone and articular cartilage","codes":{"SNOMED-CT":["312894000"]}}, + "353295004": {"description":"Toxic diffuse goiter","codes":{"SNOMED-CT":["353295004"]}}, + "356744012": {"description":"Thiopurine methyltransferase deficiency","codes":{"SNOMED-CT":["356744012"]}}, + "363518003": {"description":"Primary malignant neoplasm of kidney","codes":{"SNOMED-CT":["363518003"]}}, + "372064008": {"description":"Primary malignant neoplasm of central portion of female breast","codes":{"SNOMED-CT":["372064008"]}}, + "386661006": {"description":"Fever","codes":{"SNOMED-CT":["386661006"]}}, + "386692008": {"description":"Excessive and frequent menstruation","codes":{"SNOMED-CT":["386692008"]}}, + "387712008": {"description":"Neonatal jaundice","codes":{"SNOMED-CT":["387712008"]}}, + "390951007": {"description":"Impaired fasting glucose","codes":{"SNOMED-CT":["390951007"]}}, + "395704004": {"description":"Heart disease","codes":{"SNOMED-CT":["395704004"]}}, + "396275006": {"description":"Osteoarthritis","codes":{"SNOMED-CT":["396275006"]}}, + "398050005": {"description":"Diverticular disease of colon","codes":{"SNOMED-CT":["398050005"]}}, + "398909004": {"description":"Rosacea","codes":{"SNOMED-CT":["398909004"]}}, + "399068003": {"description":"Primary malignant neoplasm of prostate","codes":{"SNOMED-CT":["399068003"]}}, + "399935008": {"description":"high blood glucose","codes":{"SNOMED-CT":["399935008"]}}, + "399963005": {"description":"Abrasion or friction burn of other, multiple, and unspecified sites, without mention of infection","codes":{"SNOMED-CT":["399963005"]}}, + "400210000": {"description":"Hemangioma","codes":{"SNOMED-CT":["400210000"]}}, + "404640003": {"description":"Dizzyness","codes":{"SNOMED-CT":["404640003"]}}, + "405729008": {"description":"Blood in stool","codes":{"SNOMED-CT":["405729008"]}}, + "405737000": {"description":"Acute phargygitis","codes":{"SNOMED-CT":["405737000"]}}, + "406506008": {"description":"Attention deficit hyperactivity disorder","codes":{"SNOMED-CT":["406506008"]}}, + "408643008": {"description":"Overlapping malignant neoplasm of female breast","codes":{"SNOMED-CT":["408643008"]}}, + "409622000": {"description":"Acute respiratory failure","codes":{"SNOMED-CT":["409622000"]}}, + "413838009": {"description":"Chronic ischemic heart disease","codes":{"SNOMED-CT":["413838009"]}}, + "414795007": {"description":"Chronic ischemic heart disease","codes":{"SNOMED-CT":["414795007"]}}, + "414941008": {"description":"Onychomycosis due to dermatophyte","codes":{"SNOMED-CT":["414941008"]}}, + "415081006": {"description":"History of malignant neoplasm of kidney","codes":{"SNOMED-CT":["415081006"]}}, + "421961002": {"description":"Allergy","codes":{"SNOMED-CT":["421961002"]}}, + "422400008": {"description":"Vomiting","codes":{"SNOMED-CT":["422400008"]}}, + "422587007": {"description":"Nausea","codes":{"SNOMED-CT":["422587007"]}}, + "425229001": {"description":"foreign body in larynx","codes":{"SNOMED-CT":["425229001"]}}, + "427359005": {"description":"Disorder of lung","codes":{"SNOMED-CT":["427359005"]}}, + "428007007": {"description":"Erectile dysfunction","codes":{"SNOMED-CT":["428007007"]}}, + "429047008": {"description":"History of polyp of colon","codes":{"SNOMED-CT":["429047008"]}}, + "429305003": {"description":"Insect bite, nonvenomous, of other, multiple, and unspecified sites, without mention of infection","codes":{"SNOMED-CT":["429305003"]}}, + "429484003": {"description":"History of malignant neoplasm of cervix","codes":{"SNOMED-CT":["429484003"]}}, + "429494008": {"description":"Seroma complicating a procedure","codes":{"SNOMED-CT":["429494008"]}}, + "429998004": {"description":"Vascular dementia, uncomplicated","codes":{"SNOMED-CT":["429998004"]}}, + "431737008": {"description":"UTI","codes":{"SNOMED-CT":["431737008"]}}, + "441087007": {"description":"Papanicolaou smear of cervix with atypical squamous cells of undetermined significance (ASC-US)","codes":{"SNOMED-CT":["441087007"]}}, + "442311008": {"description":"Single liveborn, born in hospital, delivered without mention of cesarean section","codes":{"SNOMED-CT":["442311008"]}}, + "443092002": {"description":"Exostosis of unspecified site","codes":{"SNOMED-CT":["443092002"]}}, + "448952004": {"description":"Infiltrating duct carcinoma of female breast","codes":{"SNOMED-CT":["448952004"]}} + } + }, + + // Records per page + patientsPerPage: 10, + + // AJAX requests timeout (ms) + timeout: 20000, + + // Only the selected patients are rendered. Should be false or the + // preselected patient IDs should be passed to the window. Otherwise + // It will result in rendering no patients at all. + renderSelectedOnly: false, + + // If enabled is true (then url and param MUST be set) then clicking on the + // patient-related resources in detail view will open their source in that + // external viewer. Otherwise they will just be opened in new browser tab. + fhirViewer: { + enabled: true, + url : "http://docs.smarthealthit.org/fhir-viewer/index.html", + param : "url" + }, + + // What to send when the OK dialog button is clicked. Possible values: + // "id-list" - comma-separated list of patient IDs (default) + // "id-array" - array of patient IDs + // "patients" - array of patient JSON objects + outputMode: "id-list", + + // "automatic" -> onChange plus defer in some cases + // "manual" -> render a submit button + submitStrategy: "manual" +} \ No newline at end of file diff --git a/build/examples/index.html b/build/examples/index.html new file mode 100644 index 0000000..94a2f92 --- /dev/null +++ b/build/examples/index.html @@ -0,0 +1,138 @@ + + + + + SMART Patient Browser Examples + + + + + + + +
+
+
+ +
+ + + + +
+
+
+ +
+ + + + +
+
+
+ + + + + \ No newline at end of file diff --git a/build/examples/picker.html b/build/examples/picker.html new file mode 100644 index 0000000..3e1022d --- /dev/null +++ b/build/examples/picker.html @@ -0,0 +1,19 @@ + + + + + SMART Patient Browser + + + + + + +
+ + + + + + + \ No newline at end of file diff --git a/build/img/icon.png b/build/img/icon.png new file mode 100644 index 0000000..1fd1da0 Binary files /dev/null and b/build/img/icon.png differ diff --git a/build/js/.gitkeep b/build/js/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/build/js/vendor/babel-polyfill.min.js b/build/js/vendor/babel-polyfill.min.js new file mode 100644 index 0000000..52446f4 --- /dev/null +++ b/build/js/vendor/babel-polyfill.min.js @@ -0,0 +1,4 @@ +!function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var c="function"==typeof require&&require;if(!u&&c)return c(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var a=n[o]={exports:{}};t[o][0].call(a.exports,function(n){var r=t[o][1][n];return s(r?r:n)},a,a.exports,e,t,n,r)}return n[o].exports}for(var i="function"==typeof require&&require,o=0;o2?arguments[2]:void 0,s=Math.min((void 0===a?u:i(a,u))-f,u-c),l=1;for(f0;)f in r?r[c]=r[f]:delete r[c],c+=l,f+=l;return r}},{105:105,108:108,109:109}],9:[function(t,n,r){"use strict";var e=t(109),i=t(105),o=t(108);n.exports=function fill(t){for(var n=e(this),r=o(n.length),u=arguments.length,c=i(u>1?arguments[1]:void 0,r),f=u>2?arguments[2]:void 0,a=void 0===f?r:i(f,r);a>c;)n[c++]=t;return n}},{105:105,108:108,109:109}],10:[function(t,n,r){var e=t(37);n.exports=function(t,n){var r=[];return e(t,!1,r.push,r,n),r}},{37:37}],11:[function(t,n,r){var e=t(107),i=t(108),o=t(105);n.exports=function(t){return function(n,r,u){var c,f=e(n),a=i(f.length),s=o(u,a);if(t&&r!=r){for(;a>s;)if(c=f[s++],c!=c)return!0}else for(;a>s;s++)if((t||s in f)&&f[s]===r)return t||s||0;return!t&&-1}}},{105:105,107:107,108:108}],12:[function(t,n,r){var e=t(25),i=t(45),o=t(109),u=t(108),c=t(15);n.exports=function(t,n){var r=1==t,f=2==t,a=3==t,s=4==t,l=6==t,h=5==t||l,v=n||c;return function(n,c,p){for(var d,y,g=o(n),b=i(g),x=e(c,p,3),m=u(b.length),w=0,S=r?v(n,m):f?v(n,0):void 0;m>w;w++)if((h||w in b)&&(d=b[w],y=x(d,w,g),t))if(r)S[w]=y;else if(y)switch(t){case 3:return!0;case 5:return d;case 6:return w;case 2:S.push(d)}else if(s)return!1;return l?-1:a||s?s:S}}},{108:108,109:109,15:15,25:25,45:45}],13:[function(t,n,r){var e=t(3),i=t(109),o=t(45),u=t(108);n.exports=function(t,n,r,c,f){e(n);var a=i(t),s=o(a),l=u(a.length),h=f?l-1:0,v=f?-1:1;if(r<2)for(;;){if(h in s){c=s[h],h+=v;break}if(h+=v,f?h<0:l<=h)throw TypeError("Reduce of empty array with no initial value")}for(;f?h>=0:l>h;h+=v)h in s&&(c=n(c,s[h],h,a));return c}},{108:108,109:109,3:3,45:45}],14:[function(t,n,r){var e=t(49),i=t(47),o=t(117)("species");n.exports=function(t){var n;return i(t)&&(n=t.constructor,"function"!=typeof n||n!==Array&&!i(n.prototype)||(n=void 0),e(n)&&(n=n[o],null===n&&(n=void 0))),void 0===n?Array:n}},{117:117,47:47,49:49}],15:[function(t,n,r){var e=t(14);n.exports=function(t,n){return new(e(t))(n)}},{14:14}],16:[function(t,n,r){"use strict";var e=t(3),i=t(49),o=t(44),u=[].slice,c={},f=function(t,n,r){if(!(n in c)){for(var e=[],i=0;i1?arguments[1]:void 0,3);n=n?n.n:this._f;)for(r(n.v,n.k,this);n&&n.r;)n=n.p},has:function has(t){return!!y(this,t)}}),v&&e(l.prototype,"size",{get:function(){return f(this[d])}}),l},def:function(t,n,r){var e,i,o=y(t,n);return o?o.v=r:(t._l=o={i:i=p(n,!0),k:n,v:r,p:e=t._l,n:void 0,r:!1},t._f||(t._f=o),e&&(e.n=o),t[d]++,"F"!==i&&(t._i[i]=o)),t},getEntry:y,setStrong:function(t,n,r){s(t,n,function(t,n){this._t=t,this._k=n,this._l=void 0},function(){for(var t=this,n=t._k,r=t._l;r&&r.r;)r=r.p;return t._t&&(t._l=r=r?r.n:t._t._f)?"keys"==n?l(0,r.k):"values"==n?l(0,r.v):l(0,[r.k,r.v]):(t._t=void 0,l(1))},r?"entries":"values",!r,!0),h(n)}}},{25:25,27:27,28:28,37:37,53:53,55:55,6:6,62:62,66:66,67:67,86:86,91:91}],20:[function(t,n,r){var e=t(17),i=t(10);n.exports=function(t){return function toJSON(){if(e(this)!=t)throw TypeError(t+"#toJSON isn't generic");return i(this)}}},{10:10,17:17}],21:[function(t,n,r){"use strict";var e=t(86),i=t(62).getWeak,o=t(7),u=t(49),c=t(6),f=t(37),a=t(12),s=t(39),l=a(5),h=a(6),v=0,p=function(t){return t._l||(t._l=new d)},d=function(){this.a=[]},y=function(t,n){return l(t.a,function(t){return t[0]===n})};d.prototype={get:function(t){var n=y(this,t);if(n)return n[1]},has:function(t){return!!y(this,t)},set:function(t,n){var r=y(this,t);r?r[1]=n:this.a.push([t,n])},delete:function(t){var n=h(this.a,function(n){return n[0]===t});return~n&&this.a.splice(n,1),!!~n}},n.exports={getConstructor:function(t,n,r,o){var a=t(function(t,e){c(t,a,n,"_i"),t._i=v++,t._l=void 0,void 0!=e&&f(e,r,t[o],t)});return e(a.prototype,{delete:function(t){if(!u(t))return!1;var n=i(t);return n===!0?p(this).delete(t):n&&s(n,this._i)&&delete n[this._i]},has:function has(t){if(!u(t))return!1;var n=i(t);return n===!0?p(this).has(t):n&&s(n,this._i)}}),a},def:function(t,n,r){var e=i(o(n),!0);return e===!0?p(t).set(n,r):e[t._i]=r,t},ufstore:p}},{12:12,37:37,39:39,49:49,6:6,62:62,7:7,86:86}],22:[function(t,n,r){"use strict";var e=t(38),i=t(32),o=t(87),u=t(86),c=t(62),f=t(37),a=t(6),s=t(49),l=t(34),h=t(54),v=t(92),p=t(43);n.exports=function(t,n,r,d,y,g){var b=e[t],x=b,m=y?"set":"add",w=x&&x.prototype,S={},_=function(t){var n=w[t];o(w,t,"delete"==t?function(t){return!(g&&!s(t))&&n.call(this,0===t?0:t)}:"has"==t?function has(t){return!(g&&!s(t))&&n.call(this,0===t?0:t)}:"get"==t?function get(t){return g&&!s(t)?void 0:n.call(this,0===t?0:t)}:"add"==t?function add(t){return n.call(this,0===t?0:t),this}:function set(t,r){return n.call(this,0===t?0:t,r),this})};if("function"==typeof x&&(g||w.forEach&&!l(function(){(new x).entries().next()}))){var E=new x,O=E[m](g?{}:-0,1)!=E,F=l(function(){E.has(1)}),P=h(function(t){new x(t)}),M=!g&&l(function(){for(var t=new x,n=5;n--;)t[m](n,n);return!t.has(-0)});P||(x=n(function(n,r){a(n,x,t);var e=p(new b,n,x);return void 0!=r&&f(r,y,e[m],e),e}),x.prototype=w,w.constructor=x),(F||M)&&(_("delete"),_("has"),y&&_("get")),(M||O)&&_(m),g&&w.clear&&delete w.clear}else x=d.getConstructor(n,t,y,m),u(x.prototype,r),c.NEED=!0;return v(x,t),S[t]=x,i(i.G+i.W+i.F*(x!=b),S),g||d.setStrong(x,t,y),x}},{32:32,34:34,37:37,38:38,43:43,49:49,54:54,6:6,62:62,86:86,87:87,92:92}],23:[function(t,n,r){var e=n.exports={version:"2.4.0"};"number"==typeof __e&&(__e=e)},{}],24:[function(t,n,r){"use strict";var e=t(67),i=t(85);n.exports=function(t,n,r){n in t?e.f(t,n,i(0,r)):t[n]=r}},{67:67,85:85}],25:[function(t,n,r){var e=t(3);n.exports=function(t,n,r){if(e(t),void 0===n)return t;switch(r){case 1:return function(r){return t.call(n,r)};case 2:return function(r,e){return t.call(n,r,e)};case 3:return function(r,e,i){return t.call(n,r,e,i)}}return function(){return t.apply(n,arguments)}}},{3:3}],26:[function(t,n,r){"use strict";var e=t(7),i=t(110),o="number";n.exports=function(t){if("string"!==t&&t!==o&&"default"!==t)throw TypeError("Incorrect hint");return i(e(this),t!=o)}},{110:110,7:7}],27:[function(t,n,r){n.exports=function(t){if(void 0==t)throw TypeError("Can't call method on "+t);return t}},{}],28:[function(t,n,r){n.exports=!t(34)(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},{34:34}],29:[function(t,n,r){var e=t(49),i=t(38).document,o=e(i)&&e(i.createElement);n.exports=function(t){return o?i.createElement(t):{}}},{38:38,49:49}],30:[function(t,n,r){n.exports="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(",")},{}],31:[function(t,n,r){var e=t(76),i=t(73),o=t(77);n.exports=function(t){var n=e(t),r=i.f;if(r)for(var u,c=r(t),f=o.f,a=0;c.length>a;)f.call(t,u=c[a++])&&n.push(u);return n}},{73:73,76:76,77:77}],32:[function(t,n,r){var e=t(38),i=t(23),o=t(40),u=t(87),c=t(25),f="prototype",a=function(t,n,r){var s,l,h,v,p=t&a.F,d=t&a.G,y=t&a.S,g=t&a.P,b=t&a.B,x=d?e:y?e[n]||(e[n]={}):(e[n]||{})[f],m=d?i:i[n]||(i[n]={}),w=m[f]||(m[f]={});d&&(r=n);for(s in r)l=!p&&x&&void 0!==x[s],h=(l?x:r)[s],v=b&&l?c(h,e):g&&"function"==typeof h?c(Function.call,h):h,x&&u(x,s,h,t&a.U),m[s]!=h&&o(m,s,v),g&&w[s]!=h&&(w[s]=h)};e.core=i,a.F=1,a.G=2,a.S=4,a.P=8,a.B=16,a.W=32,a.U=64,a.R=128,n.exports=a},{23:23,25:25,38:38,40:40,87:87}],33:[function(t,n,r){var e=t(117)("match");n.exports=function(t){var n=/./;try{"/./"[t](n)}catch(r){try{return n[e]=!1,!"/./"[t](n)}catch(t){}}return!0}},{117:117}],34:[function(t,n,r){n.exports=function(t){try{return!!t()}catch(t){return!0}}},{}],35:[function(t,n,r){"use strict";var e=t(40),i=t(87),o=t(34),u=t(27),c=t(117);n.exports=function(t,n,r){var f=c(t),a=r(u,f,""[t]),s=a[0],l=a[1];o(function(){var n={};return n[f]=function(){return 7},7!=""[t](n)})&&(i(String.prototype,t,s),e(RegExp.prototype,f,2==n?function(t,n){return l.call(t,this,n)}:function(t){return l.call(t,this)}))}},{117:117,27:27,34:34,40:40,87:87}],36:[function(t,n,r){"use strict";var e=t(7);n.exports=function(){var t=e(this),n="";return t.global&&(n+="g"),t.ignoreCase&&(n+="i"),t.multiline&&(n+="m"),t.unicode&&(n+="u"),t.sticky&&(n+="y"),n}},{7:7}],37:[function(t,n,r){var e=t(25),i=t(51),o=t(46),u=t(7),c=t(108),f=t(118),a={},s={},r=n.exports=function(t,n,r,l,h){var v,p,d,y,g=h?function(){return t}:f(t),b=e(r,l,n?2:1),x=0;if("function"!=typeof g)throw TypeError(t+" is not iterable!");if(o(g)){for(v=c(t.length);v>x;x++)if(y=n?b(u(p=t[x])[0],p[1]):b(t[x]),y===a||y===s)return y}else for(d=g.call(t);!(p=d.next()).done;)if(y=i(d,b,p.value,n),y===a||y===s)return y};r.BREAK=a,r.RETURN=s},{108:108,118:118,25:25,46:46,51:51,7:7}],38:[function(t,n,r){var e=n.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=e)},{}],39:[function(t,n,r){var e={}.hasOwnProperty;n.exports=function(t,n){return e.call(t,n)}},{}],40:[function(t,n,r){var e=t(67),i=t(85);n.exports=t(28)?function(t,n,r){return e.f(t,n,i(1,r))}:function(t,n,r){return t[n]=r,t}},{28:28,67:67,85:85}],41:[function(t,n,r){n.exports=t(38).document&&document.documentElement},{38:38}],42:[function(t,n,r){n.exports=!t(28)&&!t(34)(function(){return 7!=Object.defineProperty(t(29)("div"),"a",{get:function(){return 7}}).a})},{28:28,29:29,34:34}],43:[function(t,n,r){var e=t(49),i=t(90).set;n.exports=function(t,n,r){var o,u=n.constructor;return u!==r&&"function"==typeof u&&(o=u.prototype)!==r.prototype&&e(o)&&i&&i(t,o),t}},{49:49,90:90}],44:[function(t,n,r){n.exports=function(t,n,r){var e=void 0===r;switch(n.length){case 0:return e?t():t.call(r);case 1:return e?t(n[0]):t.call(r,n[0]);case 2:return e?t(n[0],n[1]):t.call(r,n[0],n[1]);case 3:return e?t(n[0],n[1],n[2]):t.call(r,n[0],n[1],n[2]);case 4:return e?t(n[0],n[1],n[2],n[3]):t.call(r,n[0],n[1],n[2],n[3])}return t.apply(r,n)}},{}],45:[function(t,n,r){var e=t(18);n.exports=Object("z").propertyIsEnumerable(0)?Object:function(t){return"String"==e(t)?t.split(""):Object(t)}},{18:18}],46:[function(t,n,r){var e=t(56),i=t(117)("iterator"),o=Array.prototype;n.exports=function(t){return void 0!==t&&(e.Array===t||o[i]===t)}},{117:117,56:56}],47:[function(t,n,r){var e=t(18);n.exports=Array.isArray||function isArray(t){return"Array"==e(t)}},{18:18}],48:[function(t,n,r){var e=t(49),i=Math.floor;n.exports=function isInteger(t){return!e(t)&&isFinite(t)&&i(t)===t}},{49:49}],49:[function(t,n,r){n.exports=function(t){return"object"==typeof t?null!==t:"function"==typeof t}},{}],50:[function(t,n,r){var e=t(49),i=t(18),o=t(117)("match");n.exports=function(t){var n;return e(t)&&(void 0!==(n=t[o])?!!n:"RegExp"==i(t))}},{117:117,18:18,49:49}],51:[function(t,n,r){var e=t(7);n.exports=function(t,n,r,i){try{return i?n(e(r)[0],r[1]):n(r)}catch(n){var o=t.return;throw void 0!==o&&e(o.call(t)),n}}},{7:7}],52:[function(t,n,r){"use strict";var e=t(66),i=t(85),o=t(92),u={};t(40)(u,t(117)("iterator"),function(){return this}),n.exports=function(t,n,r){t.prototype=e(u,{next:i(1,r)}),o(t,n+" Iterator")}},{117:117,40:40,66:66,85:85,92:92}],53:[function(t,n,r){"use strict";var e=t(58),i=t(32),o=t(87),u=t(40),c=t(39),f=t(56),a=t(52),s=t(92),l=t(74),h=t(117)("iterator"),v=!([].keys&&"next"in[].keys()),p="@@iterator",d="keys",y="values",g=function(){return this};n.exports=function(t,n,r,b,x,m,w){a(r,n,b);var S,_,E,O=function(t){if(!v&&t in A)return A[t];switch(t){case d:return function keys(){return new r(this,t)};case y:return function values(){return new r(this,t)}}return function entries(){return new r(this,t)}},F=n+" Iterator",P=x==y,M=!1,A=t.prototype,I=A[h]||A[p]||x&&A[x],j=I||O(x),N=x?P?O("entries"):j:void 0,k="Array"==n?A.entries||I:I;if(k&&(E=l(k.call(new t)),E!==Object.prototype&&(s(E,F,!0),e||c(E,h)||u(E,h,g))),P&&I&&I.name!==y&&(M=!0,j=function values(){return I.call(this)}),e&&!w||!v&&!M&&A[h]||u(A,h,j),f[n]=j,f[F]=g,x)if(S={values:P?j:O(y),keys:m?j:O(d),entries:N},w)for(_ in S)_ in A||o(A,_,S[_]);else i(i.P+i.F*(v||M),n,S);return S}},{117:117,32:32,39:39,40:40,52:52,56:56,58:58,74:74,87:87,92:92}],54:[function(t,n,r){var e=t(117)("iterator"),i=!1;try{var o=[7][e]();o.return=function(){i=!0},Array.from(o,function(){throw 2})}catch(t){}n.exports=function(t,n){if(!n&&!i)return!1;var r=!1;try{var o=[7],u=o[e]();u.next=function(){return{done:r=!0}},o[e]=function(){return u},t(o)}catch(t){}return r}},{117:117}],55:[function(t,n,r){n.exports=function(t,n){return{value:n,done:!!t}}},{}],56:[function(t,n,r){n.exports={}},{}],57:[function(t,n,r){var e=t(76),i=t(107);n.exports=function(t,n){for(var r,o=i(t),u=e(o),c=u.length,f=0;c>f;)if(o[r=u[f++]]===n)return r}},{107:107,76:76}],58:[function(t,n,r){n.exports=!1},{}],59:[function(t,n,r){var e=Math.expm1;n.exports=!e||e(10)>22025.465794806718||e(10)<22025.465794806718||e(-2e-17)!=-2e-17?function expm1(t){return 0==(t=+t)?t:t>-1e-6&&t<1e-6?t+t*t/2:Math.exp(t)-1}:e},{}],60:[function(t,n,r){n.exports=Math.log1p||function log1p(t){return(t=+t)>-1e-8&&t<1e-8?t-t*t/2:Math.log(1+t)}},{}],61:[function(t,n,r){n.exports=Math.sign||function sign(t){return 0==(t=+t)||t!=t?t:t<0?-1:1}},{}],62:[function(t,n,r){var e=t(114)("meta"),i=t(49),o=t(39),u=t(67).f,c=0,f=Object.isExtensible||function(){return!0},a=!t(34)(function(){return f(Object.preventExtensions({}))}),s=function(t){u(t,e,{value:{i:"O"+ ++c,w:{}}})},l=function(t,n){if(!i(t))return"symbol"==typeof t?t:("string"==typeof t?"S":"P")+t;if(!o(t,e)){if(!f(t))return"F";if(!n)return"E";s(t)}return t[e].i},h=function(t,n){if(!o(t,e)){if(!f(t))return!0;if(!n)return!1;s(t)}return t[e].w},v=function(t){return a&&p.NEED&&f(t)&&!o(t,e)&&s(t),t},p=n.exports={KEY:e,NEED:!1,fastKey:l,getWeak:h,onFreeze:v}},{114:114,34:34,39:39,49:49,67:67}],63:[function(t,n,r){var e=t(149),i=t(32),o=t(94)("metadata"),u=o.store||(o.store=new(t(255))),c=function(t,n,r){var i=u.get(t);if(!i){if(!r)return;u.set(t,i=new e)}var o=i.get(n);if(!o){if(!r)return;i.set(n,o=new e)}return o},f=function(t,n,r){var e=c(n,r,!1);return void 0!==e&&e.has(t)},a=function(t,n,r){var e=c(n,r,!1);return void 0===e?void 0:e.get(t)},s=function(t,n,r,e){c(r,e,!0).set(t,n)},l=function(t,n){var r=c(t,n,!1),e=[];return r&&r.forEach(function(t,n){e.push(n)}),e},h=function(t){return void 0===t||"symbol"==typeof t?t:String(t)},v=function(t){i(i.S,"Reflect",t)};n.exports={store:u,map:c,has:f,get:a,set:s,keys:l,key:h,exp:v}},{149:149,255:255,32:32,94:94}],64:[function(t,n,r){var e=t(38),i=t(104).set,o=e.MutationObserver||e.WebKitMutationObserver,u=e.process,c=e.Promise,f="process"==t(18)(u);n.exports=function(){var t,n,r,a=function(){var e,i;for(f&&(e=u.domain)&&e.exit();t;){i=t.fn,t=t.next;try{i()}catch(e){throw t?r():n=void 0,e}}n=void 0,e&&e.enter()};if(f)r=function(){u.nextTick(a)};else if(o){var s=!0,l=document.createTextNode("");new o(a).observe(l,{characterData:!0}),r=function(){l.data=s=!s}}else if(c&&c.resolve){var h=c.resolve();r=function(){h.then(a)}}else r=function(){i.call(e,a)};return function(e){var i={fn:e,next:void 0};n&&(n.next=i),t||(t=i,r()),n=i}}},{104:104,18:18,38:38}],65:[function(t,n,r){"use strict";var e=t(76),i=t(73),o=t(77),u=t(109),c=t(45),f=Object.assign;n.exports=!f||t(34)(function(){var t={},n={},r=Symbol(),e="abcdefghijklmnopqrst";return t[r]=7,e.split("").forEach(function(t){n[t]=t}),7!=f({},t)[r]||Object.keys(f({},n)).join("")!=e})?function assign(t,n){for(var r=u(t),f=arguments.length,a=1,s=i.f,l=o.f;f>a;)for(var h,v=c(arguments[a++]),p=s?e(v).concat(s(v)):e(v),d=p.length,y=0;d>y;)l.call(v,h=p[y++])&&(r[h]=v[h]);return r}:f},{109:109,34:34,45:45,73:73,76:76,77:77}],66:[function(t,n,r){var e=t(7),i=t(68),o=t(30),u=t(93)("IE_PROTO"),c=function(){},f="prototype",a=function(){var n,r=t(29)("iframe"),e=o.length,i="<",u=">";for(r.style.display="none",t(41).appendChild(r),r.src="javascript:",n=r.contentWindow.document,n.open(),n.write(i+"script"+u+"document.F=Object"+i+"/script"+u),n.close(),a=n.F;e--;)delete a[f][o[e]];return a()};n.exports=Object.create||function create(t,n){var r;return null!==t?(c[f]=e(t),r=new c,c[f]=null,r[u]=t):r=a(),void 0===n?r:i(r,n)}},{29:29,30:30,41:41,68:68,7:7,93:93}],67:[function(t,n,r){var e=t(7),i=t(42),o=t(110),u=Object.defineProperty;r.f=t(28)?Object.defineProperty:function defineProperty(t,n,r){if(e(t),n=o(n,!0),e(r),i)try{return u(t,n,r)}catch(t){}if("get"in r||"set"in r)throw TypeError("Accessors not supported!");return"value"in r&&(t[n]=r.value),t}},{110:110,28:28,42:42,7:7}],68:[function(t,n,r){var e=t(67),i=t(7),o=t(76);n.exports=t(28)?Object.defineProperties:function defineProperties(t,n){i(t);for(var r,u=o(n),c=u.length,f=0;c>f;)e.f(t,r=u[f++],n[r]);return t}},{28:28,67:67,7:7,76:76}],69:[function(t,n,r){n.exports=t(58)||!t(34)(function(){var n=Math.random();__defineSetter__.call(null,n,function(){}),delete t(38)[n]})},{34:34,38:38,58:58}],70:[function(t,n,r){var e=t(77),i=t(85),o=t(107),u=t(110),c=t(39),f=t(42),a=Object.getOwnPropertyDescriptor;r.f=t(28)?a:function getOwnPropertyDescriptor(t,n){if(t=o(t),n=u(n,!0),f)try{return a(t,n)}catch(t){}if(c(t,n))return i(!e.f.call(t,n),t[n])}},{107:107,110:110,28:28,39:39,42:42,77:77,85:85}],71:[function(t,n,r){var e=t(107),i=t(72).f,o={}.toString,u="object"==typeof window&&window&&Object.getOwnPropertyNames?Object.getOwnPropertyNames(window):[],c=function(t){try{return i(t)}catch(t){return u.slice()}};n.exports.f=function getOwnPropertyNames(t){return u&&"[object Window]"==o.call(t)?c(t):i(e(t))}},{107:107,72:72}],72:[function(t,n,r){var e=t(75),i=t(30).concat("length","prototype");r.f=Object.getOwnPropertyNames||function getOwnPropertyNames(t){return e(t,i)}},{30:30,75:75}],73:[function(t,n,r){r.f=Object.getOwnPropertySymbols},{}],74:[function(t,n,r){var e=t(39),i=t(109),o=t(93)("IE_PROTO"),u=Object.prototype;n.exports=Object.getPrototypeOf||function(t){return t=i(t),e(t,o)?t[o]:"function"==typeof t.constructor&&t instanceof t.constructor?t.constructor.prototype:t instanceof Object?u:null}},{109:109,39:39,93:93}],75:[function(t,n,r){var e=t(39),i=t(107),o=t(11)(!1),u=t(93)("IE_PROTO");n.exports=function(t,n){var r,c=i(t),f=0,a=[];for(r in c)r!=u&&e(c,r)&&a.push(r);for(;n.length>f;)e(c,r=n[f++])&&(~o(a,r)||a.push(r));return a}},{107:107,11:11,39:39,93:93}],76:[function(t,n,r){var e=t(75),i=t(30);n.exports=Object.keys||function keys(t){return e(t,i)}},{30:30,75:75}],77:[function(t,n,r){r.f={}.propertyIsEnumerable},{}],78:[function(t,n,r){var e=t(32),i=t(23),o=t(34);n.exports=function(t,n){var r=(i.Object||{})[t]||Object[t],u={};u[t]=n(r),e(e.S+e.F*o(function(){r(1)}),"Object",u)}},{23:23,32:32,34:34}],79:[function(t,n,r){var e=t(76),i=t(107),o=t(77).f;n.exports=function(t){return function(n){for(var r,u=i(n),c=e(u),f=c.length,a=0,s=[];f>a;)o.call(u,r=c[a++])&&s.push(t?[r,u[r]]:u[r]);return s}}},{107:107,76:76,77:77}],80:[function(t,n,r){var e=t(72),i=t(73),o=t(7),u=t(38).Reflect;n.exports=u&&u.ownKeys||function ownKeys(t){var n=e.f(o(t)),r=i.f;return r?n.concat(r(t)):n}},{38:38,7:7,72:72,73:73}],81:[function(t,n,r){var e=t(38).parseFloat,i=t(102).trim;n.exports=1/e(t(103)+"-0")!==-(1/0)?function parseFloat(t){var n=i(String(t),3),r=e(n);return 0===r&&"-"==n.charAt(0)?-0:r}:e},{102:102,103:103,38:38}],82:[function(t,n,r){var e=t(38).parseInt,i=t(102).trim,o=t(103),u=/^[\-+]?0[xX]/;n.exports=8!==e(o+"08")||22!==e(o+"0x16")?function parseInt(t,n){var r=i(String(t),3);return e(r,n>>>0||(u.test(r)?16:10))}:e},{102:102,103:103,38:38}],83:[function(t,n,r){"use strict";var e=t(84),i=t(44),o=t(3);n.exports=function(){for(var t=o(this),n=arguments.length,r=Array(n),u=0,c=e._,f=!1;n>u;)(r[u]=arguments[u++])===c&&(f=!0);return function(){var e,o=this,u=arguments.length,a=0,s=0;if(!f&&!u)return i(t,r,o);if(e=r.slice(),f)for(;n>a;a++)e[a]===c&&(e[a]=arguments[s++]);for(;u>s;)e.push(arguments[s++]);return i(t,e,o)}}},{3:3,44:44,84:84}],84:[function(t,n,r){n.exports=t(38)},{38:38}],85:[function(t,n,r){n.exports=function(t,n){return{enumerable:!(1&t),configurable:!(2&t),writable:!(4&t),value:n}}},{}],86:[function(t,n,r){var e=t(87);n.exports=function(t,n,r){for(var i in n)e(t,i,n[i],r);return t}},{87:87}],87:[function(t,n,r){var e=t(38),i=t(40),o=t(39),u=t(114)("src"),c="toString",f=Function[c],a=(""+f).split(c);t(23).inspectSource=function(t){return f.call(t)},(n.exports=function(t,n,r,c){var f="function"==typeof r;f&&(o(r,"name")||i(r,"name",n)),t[n]!==r&&(f&&(o(r,u)||i(r,u,t[n]?""+t[n]:a.join(String(n)))),t===e?t[n]=r:c?t[n]?t[n]=r:i(t,n,r):(delete t[n],i(t,n,r)))})(Function.prototype,c,function toString(){return"function"==typeof this&&this[u]||f.call(this)})},{114:114,23:23,38:38,39:39,40:40}],88:[function(t,n,r){n.exports=function(t,n){var r=n===Object(n)?function(t){return n[t]}:n;return function(n){return String(n).replace(t,r)}}},{}],89:[function(t,n,r){n.exports=Object.is||function is(t,n){return t===n?0!==t||1/t===1/n:t!=t&&n!=n}},{}],90:[function(t,n,r){var e=t(49),i=t(7),o=function(t,n){if(i(t),!e(n)&&null!==n)throw TypeError(n+": can't set as prototype!")};n.exports={set:Object.setPrototypeOf||("__proto__"in{}?function(n,r,e){try{e=t(25)(Function.call,t(70).f(Object.prototype,"__proto__").set,2),e(n,[]),r=!(n instanceof Array)}catch(t){r=!0}return function setPrototypeOf(t,n){return o(t,n),r?t.__proto__=n:e(t,n),t}}({},!1):void 0),check:o}},{25:25,49:49,7:7,70:70}],91:[function(t,n,r){"use strict";var e=t(38),i=t(67),o=t(28),u=t(117)("species");n.exports=function(t){var n=e[t];o&&n&&!n[u]&&i.f(n,u,{configurable:!0,get:function(){return this}})}},{117:117,28:28,38:38,67:67}],92:[function(t,n,r){var e=t(67).f,i=t(39),o=t(117)("toStringTag");n.exports=function(t,n,r){t&&!i(t=r?t:t.prototype,o)&&e(t,o,{configurable:!0,value:n})}},{117:117,39:39,67:67}],93:[function(t,n,r){var e=t(94)("keys"),i=t(114);n.exports=function(t){return e[t]||(e[t]=i(t))}},{114:114,94:94}],94:[function(t,n,r){var e=t(38),i="__core-js_shared__",o=e[i]||(e[i]={});n.exports=function(t){return o[t]||(o[t]={})}},{38:38}],95:[function(t,n,r){var e=t(7),i=t(3),o=t(117)("species");n.exports=function(t,n){var r,u=e(t).constructor;return void 0===u||void 0==(r=e(u)[o])?n:i(r)}},{117:117,3:3,7:7}],96:[function(t,n,r){var e=t(34);n.exports=function(t,n){return!!t&&e(function(){n?t.call(null,function(){},1):t.call(null)})}},{34:34}],97:[function(t,n,r){var e=t(106),i=t(27);n.exports=function(t){return function(n,r){var o,u,c=String(i(n)),f=e(r),a=c.length;return f<0||f>=a?t?"":void 0:(o=c.charCodeAt(f),o<55296||o>56319||f+1===a||(u=c.charCodeAt(f+1))<56320||u>57343?t?c.charAt(f):o:t?c.slice(f,f+2):(o-55296<<10)+(u-56320)+65536)}}},{106:106,27:27}],98:[function(t,n,r){var e=t(50),i=t(27);n.exports=function(t,n,r){if(e(n))throw TypeError("String#"+r+" doesn't accept regex!");return String(i(t))}},{27:27,50:50}],99:[function(t,n,r){var e=t(32),i=t(34),o=t(27),u=/"/g,c=function(t,n,r,e){var i=String(o(t)),c="<"+n;return""!==r&&(c+=" "+r+'="'+String(e).replace(u,""")+'"'),c+">"+i+""};n.exports=function(t,n){var r={};r[t]=n(c),e(e.P+e.F*i(function(){var n=""[t]('"');return n!==n.toLowerCase()||n.split('"').length>3}),"String",r)}},{27:27,32:32,34:34}],100:[function(t,n,r){var e=t(108),i=t(101),o=t(27);n.exports=function(t,n,r,u){var c=String(o(t)),f=c.length,a=void 0===r?" ":String(r),s=e(n);if(s<=f||""==a)return c;var l=s-f,h=i.call(a,Math.ceil(l/a.length));return h.length>l&&(h=h.slice(0,l)),u?h+c:c+h}},{101:101,108:108,27:27}],101:[function(t,n,r){"use strict";var e=t(106),i=t(27);n.exports=function repeat(t){var n=String(i(this)),r="",o=e(t);if(o<0||o==1/0)throw RangeError("Count can't be negative");for(;o>0;(o>>>=1)&&(n+=n))1&o&&(r+=n);return r}},{106:106,27:27}],102:[function(t,n,r){var e=t(32),i=t(27),o=t(34),u=t(103),c="["+u+"]",f="​…",a=RegExp("^"+c+c+"*"),s=RegExp(c+c+"*$"),l=function(t,n,r){var i={},c=o(function(){return!!u[t]()||f[t]()!=f}),a=i[t]=c?n(h):u[t];r&&(i[r]=a),e(e.P+e.F*c,"String",i)},h=l.trim=function(t,n){return t=String(i(t)),1&n&&(t=t.replace(a,"")),2&n&&(t=t.replace(s,"")),t};n.exports=l},{103:103,27:27,32:32,34:34}],103:[function(t,n,r){n.exports="\t\n\v\f\r  ᠎              \u2028\u2029\ufeff"},{}],104:[function(t,n,r){var e,i,o,u=t(25),c=t(44),f=t(41),a=t(29),s=t(38),l=s.process,h=s.setImmediate,v=s.clearImmediate,p=s.MessageChannel,d=0,y={},g="onreadystatechange",b=function(){var t=+this;if(y.hasOwnProperty(t)){var n=y[t];delete y[t],n()}},x=function(t){b.call(t.data)};h&&v||(h=function setImmediate(t){for(var n=[],r=1;arguments.length>r;)n.push(arguments[r++]);return y[++d]=function(){c("function"==typeof t?t:Function(t),n)},e(d),d},v=function clearImmediate(t){delete y[t]},"process"==t(18)(l)?e=function(t){l.nextTick(u(b,t,1))}:p?(i=new p,o=i.port2,i.port1.onmessage=x,e=u(o.postMessage,o,1)):s.addEventListener&&"function"==typeof postMessage&&!s.importScripts?(e=function(t){s.postMessage(t+"","*")},s.addEventListener("message",x,!1)):e=g in a("script")?function(t){f.appendChild(a("script"))[g]=function(){f.removeChild(this),b.call(t)}}:function(t){setTimeout(u(b,t,1),0)}),n.exports={set:h,clear:v}},{18:18,25:25,29:29,38:38,41:41,44:44}],105:[function(t,n,r){var e=t(106),i=Math.max,o=Math.min;n.exports=function(t,n){return t=e(t),t<0?i(t+n,0):o(t,n)}},{106:106}],106:[function(t,n,r){var e=Math.ceil,i=Math.floor;n.exports=function(t){return isNaN(t=+t)?0:(t>0?i:e)(t)}},{}],107:[function(t,n,r){var e=t(45),i=t(27);n.exports=function(t){return e(i(t))}},{27:27,45:45}],108:[function(t,n,r){var e=t(106),i=Math.min;n.exports=function(t){return t>0?i(e(t),9007199254740991):0}},{106:106}],109:[function(t,n,r){var e=t(27);n.exports=function(t){return Object(e(t))}},{27:27}],110:[function(t,n,r){var e=t(49);n.exports=function(t,n){if(!e(t))return t;var r,i;if(n&&"function"==typeof(r=t.toString)&&!e(i=r.call(t)))return i;if("function"==typeof(r=t.valueOf)&&!e(i=r.call(t)))return i;if(!n&&"function"==typeof(r=t.toString)&&!e(i=r.call(t)))return i;throw TypeError("Can't convert object to primitive value")}},{49:49}],111:[function(t,n,r){"use strict";if(t(28)){var e=t(58),i=t(38),o=t(34),u=t(32),c=t(113),f=t(112),a=t(25),s=t(6),l=t(85),h=t(40),v=t(86),p=t(106),d=t(108),y=t(105),g=t(110),b=t(39),x=t(89),m=t(17),w=t(49),S=t(109),_=t(46),E=t(66),O=t(74),F=t(72).f,P=t(118),M=t(114),A=t(117),I=t(12),j=t(11),N=t(95),k=t(130),R=t(56),T=t(54),L=t(91),C=t(9),U=t(8),G=t(67),D=t(70),W=G.f,B=D.f,V=i.RangeError,z=i.TypeError,K=i.Uint8Array,J="ArrayBuffer",Y="Shared"+J,q="BYTES_PER_ELEMENT",X="prototype",$=Array[X],H=f.ArrayBuffer,Z=f.DataView,Q=I(0),tt=I(2),nt=I(3),rt=I(4),et=I(5),it=I(6),ot=j(!0),ut=j(!1),ct=k.values,ft=k.keys,at=k.entries,st=$.lastIndexOf,lt=$.reduce,ht=$.reduceRight,vt=$.join,pt=$.sort,dt=$.slice,yt=$.toString,gt=$.toLocaleString,bt=A("iterator"),xt=A("toStringTag"),mt=M("typed_constructor"),wt=M("def_constructor"),St=c.CONSTR,_t=c.TYPED,Et=c.VIEW,Ot="Wrong length!",Ft=I(1,function(t,n){return Nt(N(t,t[wt]),n)}),Pt=o(function(){return 1===new K(new Uint16Array([1]).buffer)[0]}),Mt=!!K&&!!K[X].set&&o(function(){new K(1).set({})}),At=function(t,n){if(void 0===t)throw z(Ot);var r=+t,e=d(t);if(n&&!x(r,e))throw V(Ot);return e},It=function(t,n){var r=p(t);if(r<0||r%n)throw V("Wrong offset!");return r},jt=function(t){if(w(t)&&_t in t)return t;throw z(t+" is not a typed array!")},Nt=function(t,n){if(!(w(t)&&mt in t))throw z("It is not a typed array constructor!");return new t(n)},kt=function(t,n){return Rt(N(t,t[wt]),n)},Rt=function(t,n){for(var r=0,e=n.length,i=Nt(t,e);e>r;)i[r]=n[r++];return i},Tt=function(t,n,r){W(t,n,{get:function(){return this._d[r]}})},Lt=function from(t){var n,r,e,i,o,u,c=S(t),f=arguments.length,s=f>1?arguments[1]:void 0,l=void 0!==s,h=P(c);if(void 0!=h&&!_(h)){for(u=h.call(c),e=[],n=0;!(o=u.next()).done;n++)e.push(o.value);c=e}for(l&&f>2&&(s=a(s,arguments[2],2)),n=0,r=d(c.length),i=Nt(this,r);r>n;n++)i[n]=l?s(c[n],n):c[n];return i},Ct=function of(){for(var t=0,n=arguments.length,r=Nt(this,n);n>t;)r[t]=arguments[t++];return r},Ut=!!K&&o(function(){gt.call(new K(1))}),Gt=function toLocaleString(){return gt.apply(Ut?dt.call(jt(this)):jt(this),arguments)},Dt={copyWithin:function copyWithin(t,n){return U.call(jt(this),t,n,arguments.length>2?arguments[2]:void 0)},every:function every(t){return rt(jt(this),t,arguments.length>1?arguments[1]:void 0)},fill:function fill(t){return C.apply(jt(this),arguments)},filter:function filter(t){return kt(this,tt(jt(this),t,arguments.length>1?arguments[1]:void 0))},find:function find(t){return et(jt(this),t,arguments.length>1?arguments[1]:void 0)},findIndex:function findIndex(t){ +return it(jt(this),t,arguments.length>1?arguments[1]:void 0)},forEach:function forEach(t){Q(jt(this),t,arguments.length>1?arguments[1]:void 0)},indexOf:function indexOf(t){return ut(jt(this),t,arguments.length>1?arguments[1]:void 0)},includes:function includes(t){return ot(jt(this),t,arguments.length>1?arguments[1]:void 0)},join:function join(t){return vt.apply(jt(this),arguments)},lastIndexOf:function lastIndexOf(t){return st.apply(jt(this),arguments)},map:function map(t){return Ft(jt(this),t,arguments.length>1?arguments[1]:void 0)},reduce:function reduce(t){return lt.apply(jt(this),arguments)},reduceRight:function reduceRight(t){return ht.apply(jt(this),arguments)},reverse:function reverse(){for(var t,n=this,r=jt(n).length,e=Math.floor(r/2),i=0;i1?arguments[1]:void 0)},sort:function sort(t){return pt.call(jt(this),t)},subarray:function subarray(t,n){var r=jt(this),e=r.length,i=y(t,e);return new(N(r,r[wt]))(r.buffer,r.byteOffset+i*r.BYTES_PER_ELEMENT,d((void 0===n?e:y(n,e))-i))}},Wt=function slice(t,n){return kt(this,dt.call(jt(this),t,n))},Bt=function set(t){jt(this);var n=It(arguments[1],1),r=this.length,e=S(t),i=d(e.length),o=0;if(i+n>r)throw V(Ot);for(;o255?255:255&e),i.v[p](r*n+i.o,e,Pt)},A=function(t,n){W(t,n,{get:function(){return P(this,n)},set:function(t){return M(this,n,t)},enumerable:!0})};x?(y=r(function(t,r,e,i){s(t,y,a,"_d");var o,u,c,f,l=0,v=0;if(w(r)){if(!(r instanceof H||(f=m(r))==J||f==Y))return _t in r?Rt(y,r):Lt.call(y,r);o=r,v=It(e,n);var p=r.byteLength;if(void 0===i){if(p%n)throw V(Ot);if(u=p-v,u<0)throw V(Ot)}else if(u=d(i)*n,u+v>p)throw V(Ot);c=u/n}else c=At(r,!0),u=c*n,o=new H(u);for(h(t,"_d",{b:o,o:v,l:u,e:c,v:new Z(o)});l>1,s=23===n?A(2,-24)-A(2,-77):0,l=0,h=t<0||0===t&&1/t<0?1:0;for(t=M(t),t!=t||t===F?(i=t!=t?1:0,e=f):(e=I(j(t)/N),t*(o=A(2,-e))<1&&(e--,o*=2),t+=e+a>=1?s/o:s*A(2,1-a),t*o>=2&&(e++,o/=2),e+a>=f?(i=0,e=f):e+a>=1?(i=(t*o-1)*A(2,n),e+=a):(i=t*A(2,a-1)*A(2,n),e=0));n>=8;u[l++]=255&i,i/=256,n-=8);for(e=e<0;u[l++]=255&e,e/=256,c-=8);return u[--l]|=128*h,u},D=function(t,n,r){var e,i=8*r-n-1,o=(1<>1,c=i-7,f=r-1,a=t[f--],s=127&a;for(a>>=7;c>0;s=256*s+t[f],f--,c-=8);for(e=s&(1<<-c)-1,s>>=-c,c+=n;c>0;e=256*e+t[f],f--,c-=8);if(0===s)s=1-u;else{if(s===o)return e?NaN:a?-F:F;e+=A(2,n),s-=u}return(a?-1:1)*e*A(2,s-n)},W=function(t){return t[3]<<24|t[2]<<16|t[1]<<8|t[0]},B=function(t){return[255&t]},V=function(t){return[255&t,t>>8&255]},z=function(t){return[255&t,t>>8&255,t>>16&255,t>>24&255]},K=function(t){return G(t,52,8)},J=function(t){return G(t,23,4)},Y=function(t,n,r){p(t[x],n,{get:function(){return this[r]}})},q=function(t,n,r,e){var i=+r,o=l(i);if(i!=o||o<0||o+n>t[C])throw O(w);var u=t[L]._b,c=o+t[U],f=u.slice(c,c+n);return e?f:f.reverse()},X=function(t,n,r,e,i,o){var u=+r,c=l(u);if(u!=c||c<0||c+n>t[C])throw O(w);for(var f=t[L]._b,a=c+t[U],s=e(+i),h=0;htt;)(H=Q[tt++])in S||c(S,H,P[H]);o||(Z.constructor=S)}var nt=new _(new S(2)),rt=_[x].setInt8;nt.setInt8(0,2147483648),nt.setInt8(1,2147483649),!nt.getInt8(0)&&nt.getInt8(1)||f(_[x],{setInt8:function setInt8(t,n){rt.call(this,t,n<<24>>24)},setUint8:function setUint8(t,n){rt.call(this,t,n<<24>>24)}},!0)}else S=function ArrayBuffer(t){var n=$(this,t);this._b=d.call(Array(n),0),this[C]=n},_=function DataView(t,n,r){s(this,_,b),s(t,S,b);var e=t[C],i=l(n);if(i<0||i>e)throw O("Wrong offset!");if(r=void 0===r?e-i:h(r),i+r>e)throw O(m);this[L]=t,this[U]=i,this[C]=r},i&&(Y(S,R,"_l"),Y(_,k,"_b"),Y(_,R,"_l"),Y(_,T,"_o")),f(_[x],{getInt8:function getInt8(t){return q(this,1,t)[0]<<24>>24},getUint8:function getUint8(t){return q(this,1,t)[0]},getInt16:function getInt16(t){var n=q(this,2,t,arguments[1]);return(n[1]<<8|n[0])<<16>>16},getUint16:function getUint16(t){var n=q(this,2,t,arguments[1]);return n[1]<<8|n[0]},getInt32:function getInt32(t){return W(q(this,4,t,arguments[1]))},getUint32:function getUint32(t){return W(q(this,4,t,arguments[1]))>>>0},getFloat32:function getFloat32(t){return D(q(this,4,t,arguments[1]),23,4)},getFloat64:function getFloat64(t){return D(q(this,8,t,arguments[1]),52,8)},setInt8:function setInt8(t,n){X(this,1,t,B,n)},setUint8:function setUint8(t,n){X(this,1,t,B,n)},setInt16:function setInt16(t,n){X(this,2,t,V,n,arguments[2])},setUint16:function setUint16(t,n){X(this,2,t,V,n,arguments[2])},setInt32:function setInt32(t,n){X(this,4,t,z,n,arguments[2])},setUint32:function setUint32(t,n){X(this,4,t,z,n,arguments[2])},setFloat32:function setFloat32(t,n){X(this,4,t,J,n,arguments[2])},setFloat64:function setFloat64(t,n){X(this,8,t,K,n,arguments[2])}});y(S,g),y(_,b),c(_[x],u.VIEW,!0),r[g]=S,r[b]=_},{106:106,108:108,113:113,28:28,34:34,38:38,40:40,58:58,6:6,67:67,72:72,86:86,9:9,92:92}],113:[function(t,n,r){for(var e,i=t(38),o=t(40),u=t(114),c=u("typed_array"),f=u("view"),a=!(!i.ArrayBuffer||!i.DataView),s=a,l=0,h=9,v="Int8Array,Uint8Array,Uint8ClampedArray,Int16Array,Uint16Array,Int32Array,Uint32Array,Float32Array,Float64Array".split(",");l1?arguments[1]:void 0)}}),t(5)(o)},{12:12,32:32,5:5}],125:[function(t,n,r){"use strict";var e=t(32),i=t(12)(5),o="find",u=!0;o in[]&&Array(1)[o](function(){u=!1}),e(e.P+e.F*u,"Array",{find:function find(t){return i(this,t,arguments.length>1?arguments[1]:void 0)}}),t(5)(o)},{12:12,32:32,5:5}],126:[function(t,n,r){"use strict";var e=t(32),i=t(12)(0),o=t(96)([].forEach,!0);e(e.P+e.F*!o,"Array",{forEach:function forEach(t){return i(this,t,arguments[1])}})},{12:12,32:32,96:96}],127:[function(t,n,r){"use strict";var e=t(25),i=t(32),o=t(109),u=t(51),c=t(46),f=t(108),a=t(24),s=t(118);i(i.S+i.F*!t(54)(function(t){Array.from(t)}),"Array",{from:function from(t){var n,r,i,l,h=o(t),v="function"==typeof this?this:Array,p=arguments.length,d=p>1?arguments[1]:void 0,y=void 0!==d,g=0,b=s(h);if(y&&(d=e(d,p>2?arguments[2]:void 0,2)),void 0==b||v==Array&&c(b))for(n=f(h.length),r=new v(n);n>g;g++)a(r,g,y?d(h[g],g):h[g]);else for(l=b.call(h),r=new v;!(i=l.next()).done;g++)a(r,g,y?u(l,d,[i.value,g],!0):i.value);return r.length=g,r}})},{108:108,109:109,118:118,24:24,25:25,32:32,46:46,51:51,54:54}],128:[function(t,n,r){"use strict";var e=t(32),i=t(11)(!1),o=[].indexOf,u=!!o&&1/[1].indexOf(1,-0)<0;e(e.P+e.F*(u||!t(96)(o)),"Array",{indexOf:function indexOf(t){return u?o.apply(this,arguments)||0:i(this,t,arguments[1])}})},{11:11,32:32,96:96}],129:[function(t,n,r){var e=t(32);e(e.S,"Array",{isArray:t(47)})},{32:32,47:47}],130:[function(t,n,r){"use strict";var e=t(5),i=t(55),o=t(56),u=t(107);n.exports=t(53)(Array,"Array",function(t,n){this._t=u(t),this._i=0,this._k=n},function(){var t=this._t,n=this._k,r=this._i++;return!t||r>=t.length?(this._t=void 0,i(1)):"keys"==n?i(0,r):"values"==n?i(0,t[r]):i(0,[r,t[r]])},"values"),o.Arguments=o.Array,e("keys"),e("values"),e("entries")},{107:107,5:5,53:53,55:55,56:56}],131:[function(t,n,r){"use strict";var e=t(32),i=t(107),o=[].join;e(e.P+e.F*(t(45)!=Object||!t(96)(o)),"Array",{join:function join(t){return o.call(i(this),void 0===t?",":t)}})},{107:107,32:32,45:45,96:96}],132:[function(t,n,r){"use strict";var e=t(32),i=t(107),o=t(106),u=t(108),c=[].lastIndexOf,f=!!c&&1/[1].lastIndexOf(1,-0)<0;e(e.P+e.F*(f||!t(96)(c)),"Array",{lastIndexOf:function lastIndexOf(t){if(f)return c.apply(this,arguments)||0;var n=i(this),r=u(n.length),e=r-1;for(arguments.length>1&&(e=Math.min(e,o(arguments[1]))),e<0&&(e=r+e);e>=0;e--)if(e in n&&n[e]===t)return e||0;return-1}})},{106:106,107:107,108:108,32:32,96:96}],133:[function(t,n,r){"use strict";var e=t(32),i=t(12)(1);e(e.P+e.F*!t(96)([].map,!0),"Array",{map:function map(t){return i(this,t,arguments[1])}})},{12:12,32:32,96:96}],134:[function(t,n,r){"use strict";var e=t(32),i=t(24);e(e.S+e.F*t(34)(function(){function F(){}return!(Array.of.call(F)instanceof F)}),"Array",{of:function of(){for(var t=0,n=arguments.length,r=new("function"==typeof this?this:Array)(n);n>t;)i(r,t,arguments[t++]);return r.length=n,r}})},{24:24,32:32,34:34}],135:[function(t,n,r){"use strict";var e=t(32),i=t(13);e(e.P+e.F*!t(96)([].reduceRight,!0),"Array",{reduceRight:function reduceRight(t){return i(this,t,arguments.length,arguments[1],!0)}})},{13:13,32:32,96:96}],136:[function(t,n,r){"use strict";var e=t(32),i=t(13);e(e.P+e.F*!t(96)([].reduce,!0),"Array",{reduce:function reduce(t){return i(this,t,arguments.length,arguments[1],!1)}})},{13:13,32:32,96:96}],137:[function(t,n,r){"use strict";var e=t(32),i=t(41),o=t(18),u=t(105),c=t(108),f=[].slice;e(e.P+e.F*t(34)(function(){i&&f.call(i)}),"Array",{slice:function slice(t,n){var r=c(this.length),e=o(this);if(n=void 0===n?r:n,"Array"==e)return f.call(this,t,n);for(var i=u(t,r),a=u(n,r),s=c(a-i),l=Array(s),h=0;h9?t:"0"+t};e(e.P+e.F*(i(function(){return"0385-07-25T07:06:39.999Z"!=new Date(-5e13-1).toISOString()})||!i(function(){new Date(NaN).toISOString()})),"Date",{toISOString:function toISOString(){if(!isFinite(o.call(this)))throw RangeError("Invalid time value");var t=this,n=t.getUTCFullYear(),r=t.getUTCMilliseconds(),e=n<0?"-":n>9999?"+":"";return e+("00000"+Math.abs(n)).slice(e?-6:-4)+"-"+u(t.getUTCMonth()+1)+"-"+u(t.getUTCDate())+"T"+u(t.getUTCHours())+":"+u(t.getUTCMinutes())+":"+u(t.getUTCSeconds())+"."+(r>99?r:"0"+u(r))+"Z"}})},{32:32,34:34}],143:[function(t,n,r){"use strict";var e=t(32),i=t(109),o=t(110);e(e.P+e.F*t(34)(function(){return null!==new Date(NaN).toJSON()||1!==Date.prototype.toJSON.call({toISOString:function(){return 1}})}),"Date",{toJSON:function toJSON(t){var n=i(this),r=o(n);return"number"!=typeof r||isFinite(r)?n.toISOString():null}})},{109:109,110:110,32:32,34:34}],144:[function(t,n,r){var e=t(117)("toPrimitive"),i=Date.prototype;e in i||t(40)(i,e,t(26))},{117:117,26:26,40:40}],145:[function(t,n,r){var e=Date.prototype,i="Invalid Date",o="toString",u=e[o],c=e.getTime;new Date(NaN)+""!=i&&t(87)(e,o,function toString(){var t=c.call(this);return t===t?u.call(this):i})},{87:87}],146:[function(t,n,r){var e=t(32);e(e.P,"Function",{bind:t(16)})},{16:16,32:32}],147:[function(t,n,r){"use strict";var e=t(49),i=t(74),o=t(117)("hasInstance"),u=Function.prototype;o in u||t(67).f(u,o,{value:function(t){if("function"!=typeof this||!e(t))return!1;if(!e(this.prototype))return t instanceof this;for(;t=i(t);)if(this.prototype===t)return!0;return!1}})},{117:117,49:49,67:67,74:74}],148:[function(t,n,r){var e=t(67).f,i=t(85),o=t(39),u=Function.prototype,c=/^\s*function ([^ (]*)/,f="name",a=Object.isExtensible||function(){return!0};f in u||t(28)&&e(u,f,{configurable:!0,get:function(){try{var t=this,n=(""+t).match(c)[1];return o(t,f)||!a(t)||e(t,f,i(5,n)),n}catch(t){return""}}})},{28:28,39:39,67:67,85:85}],149:[function(t,n,r){"use strict";var e=t(19);n.exports=t(22)("Map",function(t){return function Map(){return t(this,arguments.length>0?arguments[0]:void 0)}},{get:function get(t){var n=e.getEntry(this,t);return n&&n.v},set:function set(t,n){return e.def(this,0===t?0:t,n)}},e,!0)},{19:19,22:22}],150:[function(t,n,r){var e=t(32),i=t(60),o=Math.sqrt,u=Math.acosh;e(e.S+e.F*!(u&&710==Math.floor(u(Number.MAX_VALUE))&&u(1/0)==1/0),"Math",{acosh:function acosh(t){return(t=+t)<1?NaN:t>94906265.62425156?Math.log(t)+Math.LN2:i(t-1+o(t-1)*o(t+1))}})},{32:32,60:60}],151:[function(t,n,r){function asinh(t){return isFinite(t=+t)&&0!=t?t<0?-asinh(-t):Math.log(t+Math.sqrt(t*t+1)):t}var e=t(32),i=Math.asinh;e(e.S+e.F*!(i&&1/i(0)>0),"Math",{asinh:asinh})},{32:32}],152:[function(t,n,r){var e=t(32),i=Math.atanh;e(e.S+e.F*!(i&&1/i(-0)<0),"Math",{atanh:function atanh(t){return 0==(t=+t)?t:Math.log((1+t)/(1-t))/2}})},{32:32}],153:[function(t,n,r){var e=t(32),i=t(61);e(e.S,"Math",{cbrt:function cbrt(t){return i(t=+t)*Math.pow(Math.abs(t),1/3)}})},{32:32,61:61}],154:[function(t,n,r){var e=t(32);e(e.S,"Math",{clz32:function clz32(t){return(t>>>=0)?31-Math.floor(Math.log(t+.5)*Math.LOG2E):32}})},{32:32}],155:[function(t,n,r){var e=t(32),i=Math.exp;e(e.S,"Math",{cosh:function cosh(t){return(i(t=+t)+i(-t))/2}})},{32:32}],156:[function(t,n,r){var e=t(32),i=t(59);e(e.S+e.F*(i!=Math.expm1),"Math",{expm1:i})},{32:32,59:59}],157:[function(t,n,r){var e=t(32),i=t(61),o=Math.pow,u=o(2,-52),c=o(2,-23),f=o(2,127)*(2-c),a=o(2,-126),s=function(t){return t+1/u-1/u};e(e.S,"Math",{fround:function fround(t){var n,r,e=Math.abs(t),o=i(t);return ef||r!=r?o*(1/0):o*r)}})},{32:32,61:61}],158:[function(t,n,r){var e=t(32),i=Math.abs;e(e.S,"Math",{hypot:function hypot(t,n){for(var r,e,o=0,u=0,c=arguments.length,f=0;u0?(e=r/f,o+=e*e):o+=r;return f===1/0?1/0:f*Math.sqrt(o)}})},{32:32}],159:[function(t,n,r){var e=t(32),i=Math.imul;e(e.S+e.F*t(34)(function(){return i(4294967295,5)!=-5||2!=i.length}),"Math",{imul:function imul(t,n){var r=65535,e=+t,i=+n,o=r&e,u=r&i;return 0|o*u+((r&e>>>16)*u+o*(r&i>>>16)<<16>>>0)}})},{32:32,34:34}],160:[function(t,n,r){var e=t(32);e(e.S,"Math",{log10:function log10(t){return Math.log(t)/Math.LN10}})},{32:32}],161:[function(t,n,r){var e=t(32);e(e.S,"Math",{log1p:t(60)})},{32:32,60:60}],162:[function(t,n,r){var e=t(32);e(e.S,"Math",{log2:function log2(t){return Math.log(t)/Math.LN2}})},{32:32}],163:[function(t,n,r){var e=t(32);e(e.S,"Math",{sign:t(61)})},{32:32,61:61}],164:[function(t,n,r){var e=t(32),i=t(59),o=Math.exp;e(e.S+e.F*t(34)(function(){return!Math.sinh(-2e-17)!=-2e-17}),"Math",{sinh:function sinh(t){return Math.abs(t=+t)<1?(i(t)-i(-t))/2:(o(t-1)-o(-t-1))*(Math.E/2)}})},{32:32,34:34,59:59}],165:[function(t,n,r){var e=t(32),i=t(59),o=Math.exp;e(e.S,"Math",{tanh:function tanh(t){var n=i(t=+t),r=i(-t);return n==1/0?1:r==1/0?-1:(n-r)/(o(t)+o(-t))}})},{32:32,59:59}],166:[function(t,n,r){var e=t(32);e(e.S,"Math",{trunc:function trunc(t){return(t>0?Math.floor:Math.ceil)(t)}})},{32:32}],167:[function(t,n,r){"use strict";var e=t(38),i=t(39),o=t(18),u=t(43),c=t(110),f=t(34),a=t(72).f,s=t(70).f,l=t(67).f,h=t(102).trim,v="Number",p=e[v],d=p,y=p.prototype,g=o(t(66)(y))==v,b="trim"in String.prototype,x=function(t){var n=c(t,!1);if("string"==typeof n&&n.length>2){n=b?n.trim():h(n,3);var r,e,i,o=n.charCodeAt(0);if(43===o||45===o){if(r=n.charCodeAt(2),88===r||120===r)return NaN}else if(48===o){switch(n.charCodeAt(1)){case 66:case 98:e=2,i=49;break;case 79:case 111:e=8,i=55;break;default:return+n}for(var u,f=n.slice(2),a=0,s=f.length;ai)return NaN;return parseInt(f,e)}}return+n};if(!p(" 0o1")||!p("0b1")||p("+0x1")){p=function Number(t){var n=arguments.length<1?0:t,r=this;return r instanceof p&&(g?f(function(){y.valueOf.call(r)}):o(r)!=v)?u(new d(x(n)),r,p):x(n)};for(var m,w=t(28)?a(d):"MAX_VALUE,MIN_VALUE,NaN,NEGATIVE_INFINITY,POSITIVE_INFINITY,EPSILON,isFinite,isInteger,isNaN,isSafeInteger,MAX_SAFE_INTEGER,MIN_SAFE_INTEGER,parseFloat,parseInt,isInteger".split(","),S=0;w.length>S;S++)i(d,m=w[S])&&!i(p,m)&&l(p,m,s(d,m));p.prototype=y,y.constructor=p,t(87)(e,v,p)}},{102:102,110:110,18:18,28:28,34:34,38:38,39:39,43:43,66:66,67:67,70:70,72:72,87:87}],168:[function(t,n,r){var e=t(32);e(e.S,"Number",{EPSILON:Math.pow(2,-52)})},{32:32}],169:[function(t,n,r){var e=t(32),i=t(38).isFinite;e(e.S,"Number",{isFinite:function isFinite(t){return"number"==typeof t&&i(t)}})},{32:32,38:38}],170:[function(t,n,r){var e=t(32);e(e.S,"Number",{isInteger:t(48)})},{32:32,48:48}],171:[function(t,n,r){var e=t(32);e(e.S,"Number",{isNaN:function isNaN(t){return t!=t}})},{32:32}],172:[function(t,n,r){var e=t(32),i=t(48),o=Math.abs;e(e.S,"Number",{isSafeInteger:function isSafeInteger(t){return i(t)&&o(t)<=9007199254740991}})},{32:32,48:48}],173:[function(t,n,r){var e=t(32);e(e.S,"Number",{MAX_SAFE_INTEGER:9007199254740991})},{32:32}],174:[function(t,n,r){var e=t(32);e(e.S,"Number",{MIN_SAFE_INTEGER:-9007199254740991})},{32:32}],175:[function(t,n,r){var e=t(32),i=t(81);e(e.S+e.F*(Number.parseFloat!=i),"Number",{parseFloat:i})},{32:32,81:81}],176:[function(t,n,r){var e=t(32),i=t(82);e(e.S+e.F*(Number.parseInt!=i),"Number",{parseInt:i})},{32:32,82:82}],177:[function(t,n,r){"use strict";var e=t(32),i=t(106),o=t(4),u=t(101),c=1..toFixed,f=Math.floor,a=[0,0,0,0,0,0],s="Number.toFixed: incorrect invocation!",l="0",h=function(t,n){for(var r=-1,e=n;++r<6;)e+=t*a[r],a[r]=e%1e7,e=f(e/1e7)},v=function(t){for(var n=6,r=0;--n>=0;)r+=a[n],a[n]=f(r/t),r=r%t*1e7},p=function(){for(var t=6,n="";--t>=0;)if(""!==n||0===t||0!==a[t]){var r=String(a[t]);n=""===n?r:n+u.call(l,7-r.length)+r}return n},d=function(t,n,r){return 0===n?r:n%2===1?d(t,n-1,r*t):d(t*t,n/2,r)},y=function(t){for(var n=0,r=t;r>=4096;)n+=12,r/=4096;for(;r>=2;)n+=1,r/=2;return n};e(e.P+e.F*(!!c&&("0.000"!==8e-5.toFixed(3)||"1"!==.9.toFixed(0)||"1.25"!==1.255.toFixed(2)||"1000000000000000128"!==(0xde0b6b3a7640080).toFixed(0))||!t(34)(function(){c.call({})})),"Number",{toFixed:function toFixed(t){var n,r,e,c,f=o(this,s),a=i(t),g="",b=l;if(a<0||a>20)throw RangeError(s);if(f!=f)return"NaN";if(f<=-1e21||f>=1e21)return String(f);if(f<0&&(g="-",f=-f),f>1e-21)if(n=y(f*d(2,69,1))-69,r=n<0?f*d(2,-n,1):f/d(2,n,1),r*=4503599627370496,n=52-n,n>0){for(h(0,r),e=a;e>=7;)h(1e7,0),e-=7;for(h(d(10,e,1),0),e=n-1;e>=23;)v(1<<23),e-=23;v(1<0?(c=b.length,b=g+(c<=a?"0."+u.call(l,a-c)+b:b.slice(0,c-a)+"."+b.slice(c-a))):b=g+b,b}})},{101:101,106:106,32:32,34:34,4:4}],178:[function(t,n,r){"use strict";var e=t(32),i=t(34),o=t(4),u=1..toPrecision;e(e.P+e.F*(i(function(){return"1"!==u.call(1,void 0)})||!i(function(){u.call({})})),"Number",{toPrecision:function toPrecision(t){var n=o(this,"Number#toPrecision: incorrect invocation!");return void 0===t?u.call(n):u.call(n,t)}})},{32:32,34:34,4:4}],179:[function(t,n,r){var e=t(32);e(e.S+e.F,"Object",{assign:t(65)})},{32:32,65:65}],180:[function(t,n,r){var e=t(32);e(e.S,"Object",{create:t(66)})},{32:32,66:66}],181:[function(t,n,r){var e=t(32);e(e.S+e.F*!t(28),"Object",{defineProperties:t(68)})},{28:28,32:32,68:68}],182:[function(t,n,r){var e=t(32);e(e.S+e.F*!t(28),"Object",{defineProperty:t(67).f})},{28:28,32:32,67:67}],183:[function(t,n,r){var e=t(49),i=t(62).onFreeze;t(78)("freeze",function(t){return function freeze(n){return t&&e(n)?t(i(n)):n}})},{49:49,62:62,78:78}],184:[function(t,n,r){var e=t(107),i=t(70).f;t(78)("getOwnPropertyDescriptor",function(){return function getOwnPropertyDescriptor(t,n){return i(e(t),n)}})},{107:107,70:70,78:78}],185:[function(t,n,r){t(78)("getOwnPropertyNames",function(){return t(71).f})},{71:71,78:78}],186:[function(t,n,r){var e=t(109),i=t(74);t(78)("getPrototypeOf",function(){return function getPrototypeOf(t){return i(e(t))}})},{109:109,74:74,78:78}],187:[function(t,n,r){var e=t(49);t(78)("isExtensible",function(t){return function isExtensible(n){return!!e(n)&&(!t||t(n))}})},{49:49,78:78}],188:[function(t,n,r){var e=t(49);t(78)("isFrozen",function(t){return function isFrozen(n){return!e(n)||!!t&&t(n)}})},{49:49,78:78}],189:[function(t,n,r){var e=t(49);t(78)("isSealed",function(t){return function isSealed(n){return!e(n)||!!t&&t(n)}})},{49:49,78:78}],190:[function(t,n,r){var e=t(32);e(e.S,"Object",{is:t(89)})},{32:32,89:89}],191:[function(t,n,r){var e=t(109),i=t(76);t(78)("keys",function(){return function keys(t){return i(e(t))}})},{109:109,76:76,78:78}],192:[function(t,n,r){var e=t(49),i=t(62).onFreeze;t(78)("preventExtensions",function(t){return function preventExtensions(n){return t&&e(n)?t(i(n)):n}})},{49:49,62:62,78:78}],193:[function(t,n,r){var e=t(49),i=t(62).onFreeze;t(78)("seal",function(t){return function seal(n){return t&&e(n)?t(i(n)):n}})},{49:49,62:62,78:78}],194:[function(t,n,r){var e=t(32);e(e.S,"Object",{setPrototypeOf:t(90).set})},{32:32,90:90}],195:[function(t,n,r){"use strict";var e=t(17),i={};i[t(117)("toStringTag")]="z",i+""!="[object z]"&&t(87)(Object.prototype,"toString",function toString(){return"[object "+e(this)+"]"},!0)},{117:117,17:17,87:87}],196:[function(t,n,r){var e=t(32),i=t(81);e(e.G+e.F*(parseFloat!=i),{parseFloat:i})},{32:32,81:81}],197:[function(t,n,r){var e=t(32),i=t(82);e(e.G+e.F*(parseInt!=i),{parseInt:i})},{32:32,82:82}],198:[function(t,n,r){"use strict";var e,i,o,u=t(58),c=t(38),f=t(25),a=t(17),s=t(32),l=t(49),h=t(3),v=t(6),p=t(37),d=t(95),y=t(104).set,g=t(64)(),b="Promise",x=c.TypeError,m=c.process,w=c[b],m=c.process,S="process"==a(m),_=function(){},E=!!function(){try{var n=w.resolve(1),r=(n.constructor={})[t(117)("species")]=function(t){t(_,_)};return(S||"function"==typeof PromiseRejectionEvent)&&n.then(_)instanceof r}catch(t){}}(),O=function(t,n){return t===n||t===w&&n===o},F=function(t){var n;return!(!l(t)||"function"!=typeof(n=t.then))&&n},P=function(t){return O(w,t)?new M(t):new i(t)},M=i=function(t){var n,r;this.promise=new t(function(t,e){if(void 0!==n||void 0!==r)throw x("Bad Promise constructor");n=t,r=e}),this.resolve=h(n),this.reject=h(r)},A=function(t){try{t()}catch(t){return{error:t}}},I=function(t,n){if(!t._n){t._n=!0;var r=t._c;g(function(){for(var e=t._v,i=1==t._s,o=0,u=function(n){var r,o,u=i?n.ok:n.fail,c=n.resolve,f=n.reject,a=n.domain;try{u?(i||(2==t._h&&k(t),t._h=1),u===!0?r=e:(a&&a.enter(),r=u(e),a&&a.exit()),r===n.promise?f(x("Promise-chain cycle")):(o=F(r))?o.call(r,c,f):c(r)):f(e)}catch(t){f(t)}};r.length>o;)u(r[o++]);t._c=[],t._n=!1,n&&!t._h&&j(t)})}},j=function(t){y.call(c,function(){var n,r,e,i=t._v;if(N(t)&&(n=A(function(){S?m.emit("unhandledRejection",i,t):(r=c.onunhandledrejection)?r({promise:t,reason:i}):(e=c.console)&&e.error&&e.error("Unhandled promise rejection",i)}),t._h=S||N(t)?2:1),t._a=void 0,n)throw n.error})},N=function(t){if(1==t._h)return!1;for(var n,r=t._a||t._c,e=0;r.length>e;)if(n=r[e++],n.fail||!N(n.promise))return!1;return!0},k=function(t){y.call(c,function(){var n;S?m.emit("rejectionHandled",t):(n=c.onrejectionhandled)&&n({promise:t,reason:t._v})})},R=function(t){var n=this;n._d||(n._d=!0,n=n._w||n,n._v=t,n._s=2,n._a||(n._a=n._c.slice()),I(n,!0))},T=function(t){var n,r=this;if(!r._d){r._d=!0,r=r._w||r;try{if(r===t)throw x("Promise can't be resolved itself");(n=F(t))?g(function(){var e={_w:r,_d:!1};try{n.call(t,f(T,e,1),f(R,e,1))}catch(t){R.call(e,t)}}):(r._v=t,r._s=1,I(r,!1))}catch(t){R.call({_w:r,_d:!1},t)}}};E||(w=function Promise(t){v(this,w,b,"_h"),h(t),e.call(this);try{t(f(T,this,1),f(R,this,1))}catch(t){R.call(this,t)}},e=function Promise(t){this._c=[],this._a=void 0,this._s=0,this._d=!1,this._v=void 0,this._h=0,this._n=!1},e.prototype=t(86)(w.prototype,{then:function then(t,n){var r=P(d(this,w));return r.ok="function"!=typeof t||t,r.fail="function"==typeof n&&n,r.domain=S?m.domain:void 0,this._c.push(r),this._a&&this._a.push(r),this._s&&I(this,!1),r.promise},catch:function(t){return this.then(void 0,t)}}),M=function(){var t=new e;this.promise=t,this.resolve=f(T,t,1),this.reject=f(R,t,1)}),s(s.G+s.W+s.F*!E,{Promise:w}),t(92)(w,b),t(91)(b),o=t(23)[b],s(s.S+s.F*!E,b,{reject:function reject(t){var n=P(this),r=n.reject;return r(t),n.promise}}),s(s.S+s.F*(u||!E),b,{resolve:function resolve(t){if(t instanceof w&&O(t.constructor,this))return t;var n=P(this),r=n.resolve;return r(t),n.promise}}),s(s.S+s.F*!(E&&t(54)(function(t){w.all(t).catch(_)})),b,{all:function all(t){var n=this,r=P(n),e=r.resolve,i=r.reject,o=A(function(){var r=[],o=0,u=1;p(t,!1,function(t){var c=o++,f=!1;r.push(void 0),u++,n.resolve(t).then(function(t){f||(f=!0,r[c]=t,--u||e(r))},i)}),--u||e(r)});return o&&i(o.error),r.promise},race:function race(t){var n=this,r=P(n),e=r.reject,i=A(function(){p(t,!1,function(t){n.resolve(t).then(r.resolve,e)})});return i&&e(i.error),r.promise}})},{104:104,117:117,17:17,23:23,25:25,3:3,32:32,37:37,38:38,49:49,54:54,58:58,6:6,64:64,86:86,91:91,92:92,95:95}],199:[function(t,n,r){var e=t(32),i=t(3),o=t(7),u=(t(38).Reflect||{}).apply,c=Function.apply;e(e.S+e.F*!t(34)(function(){u(function(){})}),"Reflect",{apply:function apply(t,n,r){var e=i(t),f=o(r);return u?u(e,n,f):c.call(e,n,f)}})},{3:3,32:32,34:34,38:38,7:7}],200:[function(t,n,r){var e=t(32),i=t(66),o=t(3),u=t(7),c=t(49),f=t(34),a=t(16),s=(t(38).Reflect||{}).construct,l=f(function(){function F(){}return!(s(function(){},[],F)instanceof F)}),h=!f(function(){s(function(){})});e(e.S+e.F*(l||h),"Reflect",{construct:function construct(t,n){o(t),u(n);var r=arguments.length<3?t:o(arguments[2]);if(h&&!l)return s(t,n,r);if(t==r){switch(n.length){case 0:return new t;case 1:return new t(n[0]);case 2:return new t(n[0],n[1]);case 3:return new t(n[0],n[1],n[2]);case 4:return new t(n[0],n[1],n[2],n[3])}var e=[null];return e.push.apply(e,n),new(a.apply(t,e))}var f=r.prototype,v=i(c(f)?f:Object.prototype),p=Function.apply.call(t,v,n);return c(p)?p:v}})},{16:16,3:3,32:32,34:34,38:38,49:49,66:66,7:7}],201:[function(t,n,r){var e=t(67),i=t(32),o=t(7),u=t(110);i(i.S+i.F*t(34)(function(){Reflect.defineProperty(e.f({},1,{value:1}),1,{value:2})}),"Reflect",{defineProperty:function defineProperty(t,n,r){o(t),n=u(n,!0),o(r);try{return e.f(t,n,r),!0}catch(t){return!1}}})},{110:110,32:32,34:34,67:67,7:7}],202:[function(t,n,r){var e=t(32),i=t(70).f,o=t(7);e(e.S,"Reflect",{deleteProperty:function deleteProperty(t,n){var r=i(o(t),n);return!(r&&!r.configurable)&&delete t[n]}})},{32:32,7:7,70:70}],203:[function(t,n,r){"use strict";var e=t(32),i=t(7),o=function(t){this._t=i(t),this._i=0;var n,r=this._k=[];for(n in t)r.push(n)};t(52)(o,"Object",function(){var t,n=this,r=n._k;do if(n._i>=r.length)return{value:void 0,done:!0};while(!((t=r[n._i++])in n._t));return{value:t,done:!1}}),e(e.S,"Reflect",{enumerate:function enumerate(t){return new o(t)}})},{32:32,52:52,7:7}],204:[function(t,n,r){var e=t(70),i=t(32),o=t(7);i(i.S,"Reflect",{getOwnPropertyDescriptor:function getOwnPropertyDescriptor(t,n){return e.f(o(t),n)}})},{32:32,7:7,70:70}],205:[function(t,n,r){var e=t(32),i=t(74),o=t(7);e(e.S,"Reflect",{getPrototypeOf:function getPrototypeOf(t){return i(o(t))}})},{32:32,7:7,74:74}],206:[function(t,n,r){function get(t,n){var r,u,a=arguments.length<3?t:arguments[2];return f(t)===a?t[n]:(r=e.f(t,n))?o(r,"value")?r.value:void 0!==r.get?r.get.call(a):void 0:c(u=i(t))?get(u,n,a):void 0}var e=t(70),i=t(74),o=t(39),u=t(32),c=t(49),f=t(7);u(u.S,"Reflect",{get:get})},{32:32,39:39,49:49,7:7,70:70,74:74}],207:[function(t,n,r){var e=t(32);e(e.S,"Reflect",{has:function has(t,n){return n in t; +}})},{32:32}],208:[function(t,n,r){var e=t(32),i=t(7),o=Object.isExtensible;e(e.S,"Reflect",{isExtensible:function isExtensible(t){return i(t),!o||o(t)}})},{32:32,7:7}],209:[function(t,n,r){var e=t(32);e(e.S,"Reflect",{ownKeys:t(80)})},{32:32,80:80}],210:[function(t,n,r){var e=t(32),i=t(7),o=Object.preventExtensions;e(e.S,"Reflect",{preventExtensions:function preventExtensions(t){i(t);try{return o&&o(t),!0}catch(t){return!1}}})},{32:32,7:7}],211:[function(t,n,r){var e=t(32),i=t(90);i&&e(e.S,"Reflect",{setPrototypeOf:function setPrototypeOf(t,n){i.check(t,n);try{return i.set(t,n),!0}catch(t){return!1}}})},{32:32,90:90}],212:[function(t,n,r){function set(t,n,r){var c,l,h=arguments.length<4?t:arguments[3],v=i.f(a(t),n);if(!v){if(s(l=o(t)))return set(l,n,r,h);v=f(0)}return u(v,"value")?!(v.writable===!1||!s(h))&&(c=i.f(h,n)||f(0),c.value=r,e.f(h,n,c),!0):void 0!==v.set&&(v.set.call(h,r),!0)}var e=t(67),i=t(70),o=t(74),u=t(39),c=t(32),f=t(85),a=t(7),s=t(49);c(c.S,"Reflect",{set:set})},{32:32,39:39,49:49,67:67,7:7,70:70,74:74,85:85}],213:[function(t,n,r){var e=t(38),i=t(43),o=t(67).f,u=t(72).f,c=t(50),f=t(36),a=e.RegExp,s=a,l=a.prototype,h=/a/g,v=/a/g,p=new a(h)!==h;if(t(28)&&(!p||t(34)(function(){return v[t(117)("match")]=!1,a(h)!=h||a(v)==v||"/a/i"!=a(h,"i")}))){a=function RegExp(t,n){var r=this instanceof a,e=c(t),o=void 0===n;return!r&&e&&t.constructor===a&&o?t:i(p?new s(e&&!o?t.source:t,n):s((e=t instanceof a)?t.source:t,e&&o?f.call(t):n),r?this:l,a)};for(var d=(function(t){t in a||o(a,t,{configurable:!0,get:function(){return s[t]},set:function(n){s[t]=n}})}),y=u(s),g=0;y.length>g;)d(y[g++]);l.constructor=a,a.prototype=l,t(87)(e,"RegExp",a)}t(91)("RegExp")},{117:117,28:28,34:34,36:36,38:38,43:43,50:50,67:67,72:72,87:87,91:91}],214:[function(t,n,r){t(28)&&"g"!=/./g.flags&&t(67).f(RegExp.prototype,"flags",{configurable:!0,get:t(36)})},{28:28,36:36,67:67}],215:[function(t,n,r){t(35)("match",1,function(t,n,r){return[function match(r){"use strict";var e=t(this),i=void 0==r?void 0:r[n];return void 0!==i?i.call(r,e):new RegExp(r)[n](String(e))},r]})},{35:35}],216:[function(t,n,r){t(35)("replace",2,function(t,n,r){return[function replace(e,i){"use strict";var o=t(this),u=void 0==e?void 0:e[n];return void 0!==u?u.call(e,o,i):r.call(String(o),e,i)},r]})},{35:35}],217:[function(t,n,r){t(35)("search",1,function(t,n,r){return[function search(r){"use strict";var e=t(this),i=void 0==r?void 0:r[n];return void 0!==i?i.call(r,e):new RegExp(r)[n](String(e))},r]})},{35:35}],218:[function(t,n,r){t(35)("split",2,function(n,r,e){"use strict";var i=t(50),o=e,u=[].push,c="split",f="length",a="lastIndex";if("c"=="abbc"[c](/(b)*/)[1]||4!="test"[c](/(?:)/,-1)[f]||2!="ab"[c](/(?:ab)*/)[f]||4!="."[c](/(.?)(.?)/)[f]||"."[c](/()()/)[f]>1||""[c](/.?/)[f]){var s=void 0===/()??/.exec("")[1];e=function(t,n){var r=String(this);if(void 0===t&&0===n)return[];if(!i(t))return o.call(r,t,n);var e,c,l,h,v,p=[],d=(t.ignoreCase?"i":"")+(t.multiline?"m":"")+(t.unicode?"u":"")+(t.sticky?"y":""),y=0,g=void 0===n?4294967295:n>>>0,b=new RegExp(t.source,d+"g");for(s||(e=new RegExp("^"+b.source+"$(?!\\s)",d));(c=b.exec(r))&&(l=c.index+c[0][f],!(l>y&&(p.push(r.slice(y,c.index)),!s&&c[f]>1&&c[0].replace(e,function(){for(v=1;v1&&c.index=g)));)b[a]===c.index&&b[a]++;return y===r[f]?!h&&b.test("")||p.push(""):p.push(r.slice(y)),p[f]>g?p.slice(0,g):p}}else"0"[c](void 0,0)[f]&&(e=function(t,n){return void 0===t&&0===n?[]:o.call(this,t,n)});return[function split(t,i){var o=n(this),u=void 0==t?void 0:t[r];return void 0!==u?u.call(t,o,i):e.call(String(o),t,i)},e]})},{35:35,50:50}],219:[function(t,n,r){"use strict";t(214);var e=t(7),i=t(36),o=t(28),u="toString",c=/./[u],f=function(n){t(87)(RegExp.prototype,u,n,!0)};t(34)(function(){return"/a/b"!=c.call({source:"a",flags:"b"})})?f(function toString(){var t=e(this);return"/".concat(t.source,"/","flags"in t?t.flags:!o&&t instanceof RegExp?i.call(t):void 0)}):c.name!=u&&f(function toString(){return c.call(this)})},{214:214,28:28,34:34,36:36,7:7,87:87}],220:[function(t,n,r){"use strict";var e=t(19);n.exports=t(22)("Set",function(t){return function Set(){return t(this,arguments.length>0?arguments[0]:void 0)}},{add:function add(t){return e.def(this,t=0===t?0:t,t)}},e)},{19:19,22:22}],221:[function(t,n,r){"use strict";t(99)("anchor",function(t){return function anchor(n){return t(this,"a","name",n)}})},{99:99}],222:[function(t,n,r){"use strict";t(99)("big",function(t){return function big(){return t(this,"big","","")}})},{99:99}],223:[function(t,n,r){"use strict";t(99)("blink",function(t){return function blink(){return t(this,"blink","","")}})},{99:99}],224:[function(t,n,r){"use strict";t(99)("bold",function(t){return function bold(){return t(this,"b","","")}})},{99:99}],225:[function(t,n,r){"use strict";var e=t(32),i=t(97)(!1);e(e.P,"String",{codePointAt:function codePointAt(t){return i(this,t)}})},{32:32,97:97}],226:[function(t,n,r){"use strict";var e=t(32),i=t(108),o=t(98),u="endsWith",c=""[u];e(e.P+e.F*t(33)(u),"String",{endsWith:function endsWith(t){var n=o(this,t,u),r=arguments.length>1?arguments[1]:void 0,e=i(n.length),f=void 0===r?e:Math.min(i(r),e),a=String(t);return c?c.call(n,a,f):n.slice(f-a.length,f)===a}})},{108:108,32:32,33:33,98:98}],227:[function(t,n,r){"use strict";t(99)("fixed",function(t){return function fixed(){return t(this,"tt","","")}})},{99:99}],228:[function(t,n,r){"use strict";t(99)("fontcolor",function(t){return function fontcolor(n){return t(this,"font","color",n)}})},{99:99}],229:[function(t,n,r){"use strict";t(99)("fontsize",function(t){return function fontsize(n){return t(this,"font","size",n)}})},{99:99}],230:[function(t,n,r){var e=t(32),i=t(105),o=String.fromCharCode,u=String.fromCodePoint;e(e.S+e.F*(!!u&&1!=u.length),"String",{fromCodePoint:function fromCodePoint(t){for(var n,r=[],e=arguments.length,u=0;e>u;){if(n=+arguments[u++],i(n,1114111)!==n)throw RangeError(n+" is not a valid code point");r.push(n<65536?o(n):o(((n-=65536)>>10)+55296,n%1024+56320))}return r.join("")}})},{105:105,32:32}],231:[function(t,n,r){"use strict";var e=t(32),i=t(98),o="includes";e(e.P+e.F*t(33)(o),"String",{includes:function includes(t){return!!~i(this,t,o).indexOf(t,arguments.length>1?arguments[1]:void 0)}})},{32:32,33:33,98:98}],232:[function(t,n,r){"use strict";t(99)("italics",function(t){return function italics(){return t(this,"i","","")}})},{99:99}],233:[function(t,n,r){"use strict";var e=t(97)(!0);t(53)(String,"String",function(t){this._t=String(t),this._i=0},function(){var t,n=this._t,r=this._i;return r>=n.length?{value:void 0,done:!0}:(t=e(n,r),this._i+=t.length,{value:t,done:!1})})},{53:53,97:97}],234:[function(t,n,r){"use strict";t(99)("link",function(t){return function link(n){return t(this,"a","href",n)}})},{99:99}],235:[function(t,n,r){var e=t(32),i=t(107),o=t(108);e(e.S,"String",{raw:function raw(t){for(var n=i(t.raw),r=o(n.length),e=arguments.length,u=[],c=0;r>c;)u.push(String(n[c++])),c1?arguments[1]:void 0,n.length)),e=String(t);return c?c.call(n,e,r):n.slice(r,r+e.length)===e}})},{108:108,32:32,33:33,98:98}],239:[function(t,n,r){"use strict";t(99)("strike",function(t){return function strike(){return t(this,"strike","","")}})},{99:99}],240:[function(t,n,r){"use strict";t(99)("sub",function(t){return function sub(){return t(this,"sub","","")}})},{99:99}],241:[function(t,n,r){"use strict";t(99)("sup",function(t){return function sup(){return t(this,"sup","","")}})},{99:99}],242:[function(t,n,r){"use strict";t(102)("trim",function(t){return function trim(){return t(this,3)}})},{102:102}],243:[function(t,n,r){"use strict";var e=t(38),i=t(39),o=t(28),u=t(32),c=t(87),f=t(62).KEY,a=t(34),s=t(94),l=t(92),h=t(114),v=t(117),p=t(116),d=t(115),y=t(57),g=t(31),b=t(47),x=t(7),m=t(107),w=t(110),S=t(85),_=t(66),E=t(71),O=t(70),F=t(67),P=t(76),M=O.f,A=F.f,I=E.f,j=e.Symbol,N=e.JSON,k=N&&N.stringify,R="prototype",T=v("_hidden"),L=v("toPrimitive"),C={}.propertyIsEnumerable,U=s("symbol-registry"),G=s("symbols"),D=s("op-symbols"),W=Object[R],B="function"==typeof j,V=e.QObject,z=!V||!V[R]||!V[R].findChild,K=o&&a(function(){return 7!=_(A({},"a",{get:function(){return A(this,"a",{value:7}).a}})).a})?function(t,n,r){var e=M(W,n);e&&delete W[n],A(t,n,r),e&&t!==W&&A(W,n,e)}:A,J=function(t){var n=G[t]=_(j[R]);return n._k=t,n},Y=B&&"symbol"==typeof j.iterator?function(t){return"symbol"==typeof t}:function(t){return t instanceof j},q=function defineProperty(t,n,r){return t===W&&q(D,n,r),x(t),n=w(n,!0),x(r),i(G,n)?(r.enumerable?(i(t,T)&&t[T][n]&&(t[T][n]=!1),r=_(r,{enumerable:S(0,!1)})):(i(t,T)||A(t,T,S(1,{})),t[T][n]=!0),K(t,n,r)):A(t,n,r)},X=function defineProperties(t,n){x(t);for(var r,e=g(n=m(n)),i=0,o=e.length;o>i;)q(t,r=e[i++],n[r]);return t},$=function create(t,n){return void 0===n?_(t):X(_(t),n)},H=function propertyIsEnumerable(t){var n=C.call(this,t=w(t,!0));return!(this===W&&i(G,t)&&!i(D,t))&&(!(n||!i(this,t)||!i(G,t)||i(this,T)&&this[T][t])||n)},Z=function getOwnPropertyDescriptor(t,n){if(t=m(t),n=w(n,!0),t!==W||!i(G,n)||i(D,n)){var r=M(t,n);return!r||!i(G,n)||i(t,T)&&t[T][n]||(r.enumerable=!0),r}},Q=function getOwnPropertyNames(t){for(var n,r=I(m(t)),e=[],o=0;r.length>o;)i(G,n=r[o++])||n==T||n==f||e.push(n);return e},tt=function getOwnPropertySymbols(t){for(var n,r=t===W,e=I(r?D:m(t)),o=[],u=0;e.length>u;)!i(G,n=e[u++])||r&&!i(W,n)||o.push(G[n]);return o};B||(j=function Symbol(){if(this instanceof j)throw TypeError("Symbol is not a constructor!");var t=h(arguments.length>0?arguments[0]:void 0),n=function(r){this===W&&n.call(D,r),i(this,T)&&i(this[T],t)&&(this[T][t]=!1),K(this,t,S(1,r))};return o&&z&&K(W,t,{configurable:!0,set:n}),J(t)},c(j[R],"toString",function toString(){return this._k}),O.f=Z,F.f=q,t(72).f=E.f=Q,t(77).f=H,t(73).f=tt,o&&!t(58)&&c(W,"propertyIsEnumerable",H,!0),p.f=function(t){return J(v(t))}),u(u.G+u.W+u.F*!B,{Symbol:j});for(var nt="hasInstance,isConcatSpreadable,iterator,match,replace,search,species,split,toPrimitive,toStringTag,unscopables".split(","),rt=0;nt.length>rt;)v(nt[rt++]);for(var nt=P(v.store),rt=0;nt.length>rt;)d(nt[rt++]);u(u.S+u.F*!B,"Symbol",{for:function(t){return i(U,t+="")?U[t]:U[t]=j(t)},keyFor:function keyFor(t){if(Y(t))return y(U,t);throw TypeError(t+" is not a symbol!")},useSetter:function(){z=!0},useSimple:function(){z=!1}}),u(u.S+u.F*!B,"Object",{create:$,defineProperty:q,defineProperties:X,getOwnPropertyDescriptor:Z,getOwnPropertyNames:Q,getOwnPropertySymbols:tt}),N&&u(u.S+u.F*(!B||a(function(){var t=j();return"[null]"!=k([t])||"{}"!=k({a:t})||"{}"!=k(Object(t))})),"JSON",{stringify:function stringify(t){if(void 0!==t&&!Y(t)){for(var n,r,e=[t],i=1;arguments.length>i;)e.push(arguments[i++]);return n=e[1],"function"==typeof n&&(r=n),!r&&b(n)||(n=function(t,n){if(r&&(n=r.call(this,t,n)),!Y(n))return n}),e[1]=n,k.apply(N,e)}}}),j[R][L]||t(40)(j[R],L,j[R].valueOf),l(j,"Symbol"),l(Math,"Math",!0),l(e.JSON,"JSON",!0)},{107:107,110:110,114:114,115:115,116:116,117:117,28:28,31:31,32:32,34:34,38:38,39:39,40:40,47:47,57:57,58:58,62:62,66:66,67:67,7:7,70:70,71:71,72:72,73:73,76:76,77:77,85:85,87:87,92:92,94:94}],244:[function(t,n,r){"use strict";var e=t(32),i=t(113),o=t(112),u=t(7),c=t(105),f=t(108),a=t(49),s=t(38).ArrayBuffer,l=t(95),h=o.ArrayBuffer,v=o.DataView,p=i.ABV&&s.isView,d=h.prototype.slice,y=i.VIEW,g="ArrayBuffer";e(e.G+e.W+e.F*(s!==h),{ArrayBuffer:h}),e(e.S+e.F*!i.CONSTR,g,{isView:function isView(t){return p&&p(t)||a(t)&&y in t}}),e(e.P+e.U+e.F*t(34)(function(){return!new h(2).slice(1,void 0).byteLength}),g,{slice:function slice(t,n){if(void 0!==d&&void 0===n)return d.call(u(this),t);for(var r=u(this).byteLength,e=c(t,r),i=c(void 0===n?r:n,r),o=new(l(this,h))(f(i-e)),a=new v(this),s=new v(o),p=0;e0?arguments[0]:void 0)}},d={get:function get(t){if(a(t)){var n=s(t);return n===!0?h(this).get(t):n?n[this._i]:void 0}},set:function set(t,n){return f.def(this,t,n)}},y=n.exports=t(22)("WeakMap",p,d,f,!0,!0);7!=(new y).set((Object.freeze||Object)(v),7).get(v)&&(e=f.getConstructor(p),c(e.prototype,d),u.NEED=!0,i(["delete","has","get","set"],function(t){var n=y.prototype,r=n[t];o(n,t,function(n,i){if(a(n)&&!l(n)){this._f||(this._f=new e);var o=this._f[t](n,i);return"set"==t?this:o}return r.call(this,n,i)})}))},{12:12,21:21,22:22,49:49,62:62,65:65,87:87}],256:[function(t,n,r){"use strict";var e=t(21);t(22)("WeakSet",function(t){return function WeakSet(){return t(this,arguments.length>0?arguments[0]:void 0)}},{add:function add(t){return e.def(this,t,!0)}},e,!1,!0)},{21:21,22:22}],257:[function(t,n,r){"use strict";var e=t(32),i=t(11)(!0);e(e.P,"Array",{includes:function includes(t){return i(this,t,arguments.length>1?arguments[1]:void 0)}}),t(5)("includes")},{11:11,32:32,5:5}],258:[function(t,n,r){var e=t(32),i=t(64)(),o=t(38).process,u="process"==t(18)(o);e(e.G,{asap:function asap(t){var n=u&&o.domain;i(n?n.bind(t):t)}})},{18:18,32:32,38:38,64:64}],259:[function(t,n,r){var e=t(32),i=t(18);e(e.S,"Error",{isError:function isError(t){return"Error"===i(t)}})},{18:18,32:32}],260:[function(t,n,r){var e=t(32);e(e.P+e.R,"Map",{toJSON:t(20)("Map")})},{20:20,32:32}],261:[function(t,n,r){var e=t(32);e(e.S,"Math",{iaddh:function iaddh(t,n,r,e){var i=t>>>0,o=n>>>0,u=r>>>0;return o+(e>>>0)+((i&u|(i|u)&~(i+u>>>0))>>>31)|0}})},{32:32}],262:[function(t,n,r){var e=t(32);e(e.S,"Math",{imulh:function imulh(t,n){var r=65535,e=+t,i=+n,o=e&r,u=i&r,c=e>>16,f=i>>16,a=(c*u>>>0)+(o*u>>>16);return c*f+(a>>16)+((o*f>>>0)+(a&r)>>16)}})},{32:32}],263:[function(t,n,r){var e=t(32);e(e.S,"Math",{isubh:function isubh(t,n,r,e){var i=t>>>0,o=n>>>0,u=r>>>0;return o-(e>>>0)-((~i&u|~(i^u)&i-u>>>0)>>>31)|0}})},{32:32}],264:[function(t,n,r){var e=t(32);e(e.S,"Math",{umulh:function umulh(t,n){var r=65535,e=+t,i=+n,o=e&r,u=i&r,c=e>>>16,f=i>>>16,a=(c*u>>>0)+(o*u>>>16);return c*f+(a>>>16)+((o*f>>>0)+(a&r)>>>16)}})},{32:32}],265:[function(t,n,r){"use strict";var e=t(32),i=t(109),o=t(3),u=t(67);t(28)&&e(e.P+t(69),"Object",{__defineGetter__:function __defineGetter__(t,n){u.f(i(this),t,{get:o(n),enumerable:!0,configurable:!0})}})},{109:109,28:28,3:3,32:32,67:67,69:69}],266:[function(t,n,r){"use strict";var e=t(32),i=t(109),o=t(3),u=t(67);t(28)&&e(e.P+t(69),"Object",{__defineSetter__:function __defineSetter__(t,n){u.f(i(this),t,{set:o(n),enumerable:!0,configurable:!0})}})},{109:109,28:28,3:3,32:32,67:67,69:69}],267:[function(t,n,r){var e=t(32),i=t(79)(!0);e(e.S,"Object",{entries:function entries(t){return i(t)}})},{32:32,79:79}],268:[function(t,n,r){var e=t(32),i=t(80),o=t(107),u=t(70),c=t(24);e(e.S,"Object",{getOwnPropertyDescriptors:function getOwnPropertyDescriptors(t){for(var n,r=o(t),e=u.f,f=i(r),a={},s=0;f.length>s;)c(a,n=f[s++],e(r,n));return a}})},{107:107,24:24,32:32,70:70,80:80}],269:[function(t,n,r){"use strict";var e=t(32),i=t(109),o=t(110),u=t(74),c=t(70).f;t(28)&&e(e.P+t(69),"Object",{__lookupGetter__:function __lookupGetter__(t){var n,r=i(this),e=o(t,!0);do if(n=c(r,e))return n.get;while(r=u(r))}})},{109:109,110:110,28:28,32:32,69:69,70:70,74:74}],270:[function(t,n,r){"use strict";var e=t(32),i=t(109),o=t(110),u=t(74),c=t(70).f;t(28)&&e(e.P+t(69),"Object",{__lookupSetter__:function __lookupSetter__(t){var n,r=i(this),e=o(t,!0);do if(n=c(r,e))return n.set;while(r=u(r))}})},{109:109,110:110,28:28,32:32,69:69,70:70,74:74}],271:[function(t,n,r){var e=t(32),i=t(79)(!1);e(e.S,"Object",{values:function values(t){return i(t)}})},{32:32,79:79}],272:[function(t,n,r){"use strict";var e=t(32),i=t(38),o=t(23),u=t(64)(),c=t(117)("observable"),f=t(3),a=t(7),s=t(6),l=t(86),h=t(40),v=t(37),p=v.RETURN,d=function(t){return null==t?void 0:f(t)},y=function(t){var n=t._c;n&&(t._c=void 0,n())},g=function(t){return void 0===t._o},b=function(t){g(t)||(t._o=void 0,y(t))},x=function(t,n){a(t),this._c=void 0,this._o=t,t=new m(this);try{var r=n(t),e=r;null!=r&&("function"==typeof r.unsubscribe?r=function(){e.unsubscribe()}:f(r),this._c=r)}catch(n){return void t.error(n)}g(this)&&y(this)};x.prototype=l({},{unsubscribe:function unsubscribe(){b(this)}});var m=function(t){this._s=t};m.prototype=l({},{next:function next(t){var n=this._s;if(!g(n)){var r=n._o;try{var e=d(r.next);if(e)return e.call(r,t)}catch(t){try{b(n)}finally{throw t}}}},error:function error(t){var n=this._s;if(g(n))throw t;var r=n._o;n._o=void 0;try{var e=d(r.error);if(!e)throw t;t=e.call(r,t)}catch(t){try{y(n)}finally{throw t}}return y(n),t},complete:function complete(t){var n=this._s;if(!g(n)){var r=n._o;n._o=void 0;try{var e=d(r.complete);t=e?e.call(r,t):void 0}catch(t){try{y(n)}finally{throw t}}return y(n),t}}});var w=function Observable(t){s(this,w,"Observable","_f")._f=f(t)};l(w.prototype,{subscribe:function subscribe(t){return new x(t,this._f)},forEach:function forEach(t){var n=this;return new(o.Promise||i.Promise)(function(r,e){f(t);var i=n.subscribe({next:function(n){try{return t(n)}catch(t){e(t),i.unsubscribe()}},error:e,complete:r})})}}),l(w,{from:function from(t){var n="function"==typeof this?this:w,r=d(a(t)[c]);if(r){var e=a(r.call(t));return e.constructor===n?e:new n(function(t){return e.subscribe(t)})}return new n(function(n){var r=!1;return u(function(){if(!r){try{if(v(t,!1,function(t){if(n.next(t),r)return p})===p)return}catch(t){if(r)throw t;return void n.error(t)}n.complete()}}),function(){r=!0}})},of:function of(){for(var t=0,n=arguments.length,r=Array(n);t1?arguments[1]:void 0,!1)}})},{100:100,32:32}],286:[function(t,n,r){"use strict";var e=t(32),i=t(100);e(e.P,"String",{padStart:function padStart(t){return i(this,t,arguments.length>1?arguments[1]:void 0,!0)}})},{100:100,32:32}],287:[function(t,n,r){"use strict";t(102)("trimLeft",function(t){return function trimLeft(){return t(this,1)}},"trimStart")},{102:102}],288:[function(t,n,r){"use strict";t(102)("trimRight",function(t){return function trimRight(){return t(this,2)}},"trimEnd")},{102:102}],289:[function(t,n,r){t(115)("asyncIterator")},{115:115}],290:[function(t,n,r){t(115)("observable")},{115:115}],291:[function(t,n,r){var e=t(32);e(e.S,"System",{global:t(38)})},{32:32,38:38}],292:[function(t,n,r){for(var e=t(130),i=t(87),o=t(38),u=t(40),c=t(56),f=t(117),a=f("iterator"),s=f("toStringTag"),l=c.Array,h=["NodeList","DOMTokenList","MediaList","StyleSheetList","CSSRuleList"],v=0;v<5;v++){var p,d=h[v],y=o[d],g=y&&y.prototype;if(g){g[a]||u(g,a,l),g[s]||u(g,s,d),c[d]=l;for(p in e)g[p]||i(g,p,e[p],!0)}}},{117:117,130:130,38:38,40:40,56:56,87:87}],293:[function(t,n,r){var e=t(32),i=t(104);e(e.G+e.B,{setImmediate:i.set,clearImmediate:i.clear})},{104:104,32:32}],294:[function(t,n,r){var e=t(38),i=t(32),o=t(44),u=t(83),c=e.navigator,f=!!c&&/MSIE .\./.test(c.userAgent),a=function(t){return f?function(n,r){return t(o(u,[].slice.call(arguments,2),"function"==typeof n?n:Function(n)),r)}:t};i(i.G+i.B+i.F*f,{setTimeout:a(e.setTimeout),setInterval:a(e.setInterval)})},{32:32,38:38,44:44,83:83}],295:[function(t,n,r){t(243),t(180),t(182),t(181),t(184),t(186),t(191),t(185),t(183),t(193),t(192),t(188),t(189),t(187),t(179),t(190),t(194),t(195),t(146),t(148),t(147),t(197),t(196),t(167),t(177),t(178),t(168),t(169),t(170),t(171),t(172),t(173),t(174),t(175),t(176),t(150),t(151),t(152),t(153),t(154),t(155),t(156),t(157),t(158),t(159),t(160),t(161),t(162),t(163),t(164),t(165),t(166),t(230),t(235),t(242),t(233),t(225),t(226),t(231),t(236),t(238),t(221),t(222),t(223),t(224),t(227),t(228),t(229),t(232),t(234),t(237),t(239),t(240),t(241),t(141),t(143),t(142),t(145),t(144),t(129),t(127),t(134),t(131),t(137),t(139),t(126),t(133),t(123),t(138),t(121),t(136),t(135),t(128),t(132),t(120),t(122),t(125),t(124),t(140),t(130),t(213),t(219),t(214),t(215),t(216),t(217),t(218),t(198),t(149),t(220),t(255),t(256),t(244),t(245),t(250),t(253),t(254),t(248),t(251),t(249),t(252),t(246),t(247),t(199),t(200),t(201),t(202),t(203),t(206),t(204),t(205),t(207),t(208),t(209),t(210),t(212),t(211),t(257),t(283),t(286),t(285),t(287),t(288),t(284),t(289),t(290),t(268),t(271),t(267),t(265),t(266),t(269),t(270),t(260),t(282),t(291),t(259),t(261),t(263),t(262),t(264),t(273),t(274),t(276),t(275),t(278),t(277),t(279),t(280),t(281),t(258),t(272),t(294),t(293),t(292),n.exports=t(23)},{120:120,121:121,122:122,123:123,124:124,125:125,126:126,127:127,128:128,129:129,130:130,131:131,132:132,133:133,134:134,135:135,136:136,137:137,138:138,139:139,140:140,141:141,142:142,143:143,144:144,145:145,146:146,147:147,148:148,149:149,150:150,151:151,152:152,153:153,154:154,155:155,156:156,157:157,158:158,159:159,160:160,161:161,162:162,163:163,164:164,165:165,166:166,167:167,168:168,169:169,170:170,171:171,172:172,173:173,174:174,175:175,176:176,177:177,178:178,179:179,180:180,181:181,182:182,183:183,184:184,185:185,186:186,187:187,188:188,189:189,190:190,191:191,192:192,193:193,194:194,195:195,196:196,197:197,198:198,199:199,200:200,201:201,202:202,203:203,204:204,205:205,206:206,207:207,208:208,209:209,210:210,211:211,212:212,213:213,214:214,215:215,216:216,217:217,218:218,219:219,220:220,221:221,222:222,223:223,224:224,225:225,226:226,227:227,228:228,229:229,23:23,230:230,231:231,232:232,233:233,234:234,235:235,236:236,237:237,238:238,239:239,240:240,241:241,242:242,243:243,244:244,245:245,246:246,247:247,248:248,249:249,250:250,251:251,252:252,253:253,254:254,255:255,256:256,257:257,258:258,259:259,260:260,261:261,262:262,263:263,264:264,265:265,266:266,267:267,268:268,269:269,270:270,271:271,272:272,273:273,274:274,275:275,276:276,277:277,278:278,279:279,280:280,281:281,282:282,283:283,284:284,285:285,286:286,287:287,288:288,289:289,290:290,291:291,292:292,293:293,294:294}],296:[function(t,n,r){(function(t){!function(t){"use strict";function wrap(t,n,r,e){var i=n&&n.prototype instanceof Generator?n:Generator,o=Object.create(i.prototype),u=new Context(e||[]);return o._invoke=makeInvokeMethod(t,r,u),o}function tryCatch(t,n,r){try{return{type:"normal",arg:t.call(n,r)}}catch(t){return{type:"throw",arg:t}}}function Generator(){}function GeneratorFunction(){}function GeneratorFunctionPrototype(){}function defineIteratorMethods(t){["next","throw","return"].forEach(function(n){t[n]=function(t){return this._invoke(n,t)}})}function AsyncIterator(t){function invoke(n,r,e,o){var u=tryCatch(t[n],t,r);if("throw"!==u.type){var c=u.arg,f=c.value;return f&&"object"==typeof f&&i.call(f,"__await")?Promise.resolve(f.__await).then(function(t){invoke("next",t,e,o)},function(t){invoke("throw",t,e,o)}):Promise.resolve(f).then(function(t){c.value=t,e(c)},o)}o(u.arg)}function enqueue(t,r){function callInvokeWithMethodAndArg(){return new Promise(function(n,e){invoke(t,r,n,e)})}return n=n?n.then(callInvokeWithMethodAndArg,callInvokeWithMethodAndArg):callInvokeWithMethodAndArg()}"object"==typeof process&&process.domain&&(invoke=process.domain.bind(invoke));var n;this._invoke=enqueue}function makeInvokeMethod(t,n,e){var i=s;return function invoke(o,u){if(i===h)throw new Error("Generator is already running");if(i===v){if("throw"===o)throw u;return doneResult()}for(;;){var c=e.delegate;if(c){if("return"===o||"throw"===o&&c.iterator[o]===r){e.delegate=null;var f=c.iterator.return;if(f){var a=tryCatch(f,c.iterator,u);if("throw"===a.type){o="throw",u=a.arg;continue}}if("return"===o)continue}var a=tryCatch(c.iterator[o],c.iterator,u);if("throw"===a.type){e.delegate=null,o="throw",u=a.arg;continue}o="next",u=r;var d=a.arg;if(!d.done)return i=l,d;e[c.resultName]=d.value,e.next=c.nextLoc,e.delegate=null}if("next"===o)e.sent=e._sent=u;else if("throw"===o){if(i===s)throw i=v,u;e.dispatchException(u)&&(o="next",u=r)}else"return"===o&&e.abrupt("return",u);i=h;var a=tryCatch(t,n,e);if("normal"===a.type){i=e.done?v:l;var d={value:a.arg,done:e.done};if(a.arg!==p)return d;e.delegate&&"next"===o&&(u=r)}else"throw"===a.type&&(i=v,o="throw",u=a.arg)}}}function pushTryEntry(t){var n={tryLoc:t[0]};1 in t&&(n.catchLoc=t[1]),2 in t&&(n.finallyLoc=t[2],n.afterLoc=t[3]),this.tryEntries.push(n)}function resetTryEntry(t){var n=t.completion||{};n.type="normal",delete n.arg,t.completion=n}function Context(t){this.tryEntries=[{tryLoc:"root"}],t.forEach(pushTryEntry,this),this.reset(!0)}function values(t){if(t){var n=t[u];if(n)return n.call(t);if("function"==typeof t.next)return t;if(!isNaN(t.length)){var e=-1,o=function next(){for(;++e=0;--r){var e=this.tryEntries[r],o=e.completion; +if("root"===e.tryLoc)return handle("end");if(e.tryLoc<=this.prev){var u=i.call(e,"catchLoc"),c=i.call(e,"finallyLoc");if(u&&c){if(this.prev=0;--r){var e=this.tryEntries[r];if(e.tryLoc<=this.prev&&i.call(e,"finallyLoc")&&this.prev=0;--n){var r=this.tryEntries[n];if(r.finallyLoc===t)return this.complete(r.completion,r.afterLoc),resetTryEntry(r),p}},catch:function(t){for(var n=this.tryEntries.length-1;n>=0;--n){var r=this.tryEntries[n];if(r.tryLoc===t){var e=r.completion;if("throw"===e.type){var i=e.arg;resetTryEntry(r)}return i}}throw new Error("illegal catch attempt")},delegateYield:function(t,n,r){return this.delegate={iterator:values(t),resultName:n,nextLoc:r},p}}}("object"==typeof t?t:"object"==typeof window?window:"object"==typeof self?self:this)}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}]},{},[1]); \ No newline at end of file diff --git a/build/js/vendor/jdenticon-1.4.0.js b/build/js/vendor/jdenticon-1.4.0.js new file mode 100644 index 0000000..f891a3a --- /dev/null +++ b/build/js/vendor/jdenticon-1.4.0.js @@ -0,0 +1,815 @@ +/** + * Jdenticon 1.4.0 + * http://jdenticon.com + * + * Built: 2016-12-10T17:10:50.251Z + * + * Copyright (c) 2014-2016 Daniel Mester Pirttijärvi + * + * This software is provided 'as-is', without any express or implied + * warranty. In no event will the authors be held liable for any damages + * arising from the use of this software. + * + * Permission is granted to anyone to use this software for any purpose, + * including commercial applications, and to alter it and redistribute it + * freely, subject to the following restrictions: + * + * 1. The origin of this software must not be misrepresented; you must not + * claim that you wrote the original software. If you use this software + * in a product, an acknowledgment in the product documentation would be + * appreciated but is not required. + * + * 2. Altered source versions must be plainly marked as such, and must not be + * misrepresented as being the original software. + * + * 3. This notice may not be removed or altered from any source distribution. + * + */ + +/*jslint bitwise: true */ + +(function (global, name, factory) { + var jQuery = global["jQuery"], + jdenticon = factory(global, jQuery); + + // Node.js + if (typeof module !== "undefined" && "exports" in module) { + module["exports"] = jdenticon; + } + // RequireJS + else if (typeof define === "function" && define["amd"]) { + define([], function () { return jdenticon; }); + } + // No module loader + else { + global[name] = jdenticon; + } +})(this, "jdenticon", function (global, jQuery) { + "use strict"; + + + + + /** + * Represents a point. + * @private + * @constructor + */ + function Point(x, y) { + this.x = x; + this.y = y; + }; + + + /** + * Translates and rotates a point before being passed on to the canvas context. This was previously done by the canvas context itself, + * but this caused a rendering issue in Chrome on sizes > 256 where the rotation transformation of inverted paths was not done properly. + * @param {number} x The x-coordinate of the upper left corner of the transformed rectangle. + * @param {number} y The y-coordinate of the upper left corner of the transformed rectangle. + * @param {number} size The size of the transformed rectangle. + * @param {number} rotation Rotation specified as 0 = 0 rad, 1 = 0.5π rad, 2 = π rad, 3 = 1.5π rad + * @private + * @constructor + */ + function Transform(x, y, size, rotation) { + this._x = x; + this._y = y; + this._size = size; + this._rotation = rotation; + } + Transform.prototype = { + /** + * Transforms the specified point based on the translation and rotation specification for this Transform. + * @param {number} x x-coordinate + * @param {number} y y-coordinate + * @param {number=} w The width of the transformed rectangle. If greater than 0, this will ensure the returned point is of the upper left corner of the transformed rectangle. + * @param {number=} h The height of the transformed rectangle. If greater than 0, this will ensure the returned point is of the upper left corner of the transformed rectangle. + */ + transformPoint: function (x, y, w, h) { + var right = this._x + this._size, + bottom = this._y + this._size; + return this._rotation === 1 ? new Point(right - y - (h || 0), this._y + x) : + this._rotation === 2 ? new Point(right - x - (w || 0), bottom - y - (h || 0)) : + this._rotation === 3 ? new Point(this._x + y, bottom - x - (w || 0)) : + new Point(this._x + x, this._y + y); + } + }; + Transform.noTransform = new Transform(0, 0, 0, 0); + + + + /** + * Provides helper functions for rendering common basic shapes. + * @private + * @constructor + */ + function Graphics(renderer) { + this._renderer = renderer; + this._transform = Transform.noTransform; + } + Graphics.prototype = { + /** + * Adds a polygon to the underlying renderer. + * @param {Array} points The points of the polygon clockwise on the format [ x0, y0, x1, y1, ..., xn, yn ] + * @param {boolean=} invert Specifies if the polygon will be inverted. + */ + addPolygon: function (points, invert) { + var di = invert ? -2 : 2, + transform = this._transform, + transformedPoints = [], + i; + + for (i = invert ? points.length - 2 : 0; i < points.length && i >= 0; i += di) { + transformedPoints.push(transform.transformPoint(points[i], points[i + 1])); + } + + this._renderer.addPolygon(transformedPoints); + }, + + /** + * Adds a polygon to the underlying renderer. + * Source: http://stackoverflow.com/a/2173084 + * @param {number} x The x-coordinate of the upper left corner of the rectangle holding the entire ellipse. + * @param {number} y The y-coordinate of the upper left corner of the rectangle holding the entire ellipse. + * @param {number} size The size of the ellipse. + * @param {boolean=} invert Specifies if the ellipse will be inverted. + */ + addCircle: function (x, y, size, invert) { + var p = this._transform.transformPoint(x, y, size, size); + this._renderer.addCircle(p, size, invert); + }, + + /** + * Adds a rectangle to the underlying renderer. + * @param {number} x The x-coordinate of the upper left corner of the rectangle. + * @param {number} y The y-coordinate of the upper left corner of the rectangle. + * @param {number} w The width of the rectangle. + * @param {number} h The height of the rectangle. + * @param {boolean=} invert Specifies if the rectangle will be inverted. + */ + addRectangle: function (x, y, w, h, invert) { + this.addPolygon([ + x, y, + x + w, y, + x + w, y + h, + x, y + h + ], invert); + }, + + /** + * Adds a right triangle to the underlying renderer. + * @param {number} x The x-coordinate of the upper left corner of the rectangle holding the triangle. + * @param {number} y The y-coordinate of the upper left corner of the rectangle holding the triangle. + * @param {number} w The width of the triangle. + * @param {number} h The height of the triangle. + * @param {number} r The rotation of the triangle (clockwise). 0 = right corner of the triangle in the lower left corner of the bounding rectangle. + * @param {boolean=} invert Specifies if the triangle will be inverted. + */ + addTriangle: function (x, y, w, h, r, invert) { + var points = [ + x + w, y, + x + w, y + h, + x, y + h, + x, y + ]; + points.splice(((r || 0) % 4) * 2, 2); + this.addPolygon(points, invert); + }, + + /** + * Adds a rhombus to the underlying renderer. + * @param {number} x The x-coordinate of the upper left corner of the rectangle holding the rhombus. + * @param {number} y The y-coordinate of the upper left corner of the rectangle holding the rhombus. + * @param {number} w The width of the rhombus. + * @param {number} h The height of the rhombus. + * @param {boolean=} invert Specifies if the rhombus will be inverted. + */ + addRhombus: function (x, y, w, h, invert) { + this.addPolygon([ + x + w / 2, y, + x + w, y + h / 2, + x + w / 2, y + h, + x, y + h / 2 + ], invert); + } + }; + + + + + var shapes = { + center: [ + /** @param {Graphics} g */ + function (g, cell, index) { + var k = cell * 0.42; + g.addPolygon([ + 0, 0, + cell, 0, + cell, cell - k * 2, + cell - k, cell, + 0, cell + ]); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + var w = 0 | (cell * 0.5), + h = 0 | (cell * 0.8); + g.addTriangle(cell - w, 0, w, h, 2); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + var s = 0 | (cell / 3); + g.addRectangle(s, s, cell - s, cell - s); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + var inner = cell * 0.1, + inner = + inner > 1 ? (0 | inner) : // large icon => truncate decimals + inner > 0.5 ? 1 : // medium size icon => fixed width + inner, // small icon => anti-aliased border + + // Use fixed outer border widths in small icons to ensure the border is drawn + outer = + cell < 6 ? 1 : + cell < 8 ? 2 : + (0 | (cell * 0.25)); + + g.addRectangle(outer, outer, cell - inner - outer, cell - inner - outer); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + var m = 0 | (cell * 0.15), + s = 0 | (cell * 0.5); + g.addCircle(cell - s - m, cell - s - m, s); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + var inner = cell * 0.1, + outer = inner * 4; + + g.addRectangle(0, 0, cell, cell); + g.addPolygon([ + outer, outer, + cell - inner, outer, + outer + (cell - outer - inner) / 2, cell - inner + ], true); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + g.addPolygon([ + 0, 0, + cell, 0, + cell, cell * 0.7, + cell * 0.4, cell * 0.4, + cell * 0.7, cell, + 0, cell + ]); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + g.addTriangle(cell / 2, cell / 2, cell / 2, cell / 2, 3); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + g.addRectangle(0, 0, cell, cell / 2); + g.addRectangle(0, cell / 2, cell / 2, cell / 2); + g.addTriangle(cell / 2, cell / 2, cell / 2, cell / 2, 1); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + var inner = cell * 0.14, + inner = + cell < 8 ? inner : // small icon => anti-aliased border + (0 | inner), // large icon => truncate decimals + + // Use fixed outer border widths in small icons to ensure the border is drawn + outer = + cell < 4 ? 1 : + cell < 6 ? 2 : + (0 | (cell * 0.35)); + + g.addRectangle(0, 0, cell, cell); + g.addRectangle(outer, outer, cell - outer - inner, cell - outer - inner, true); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + var inner = cell * 0.12, + outer = inner * 3; + + g.addRectangle(0, 0, cell, cell); + g.addCircle(outer, outer, cell - inner - outer, true); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + g.addTriangle(cell / 2, cell / 2, cell / 2, cell / 2, 3); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + var m = cell * 0.25; + g.addRectangle(0, 0, cell, cell); + g.addRhombus(m, m, cell - m, cell - m, true); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + var m = cell * 0.4, s = cell * 1.2; + if (!index) { + g.addCircle(m, m, s); + } + } + ], + + outer: [ + /** @param {Graphics} g */ + function (g, cell, index) { + g.addTriangle(0, 0, cell, cell, 0); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + g.addTriangle(0, cell / 2, cell, cell / 2, 0); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + g.addRhombus(0, 0, cell, cell); + }, + /** @param {Graphics} g */ + function (g, cell, index) { + var m = cell / 6; + g.addCircle(m, m, cell - 2 * m); + } + ] + }; + + + + + function decToHex(v) { + v |= 0; // Ensure integer value + return v < 0 ? "00" : + v < 16 ? "0" + v.toString(16) : + v < 256 ? v.toString(16) : + "ff"; + } + + function hueToRgb(m1, m2, h) { + h = h < 0 ? h + 6 : h > 6 ? h - 6 : h; + return decToHex(255 * ( + h < 1 ? m1 + (m2 - m1) * h : + h < 3 ? m2 : + h < 4 ? m1 + (m2 - m1) * (4 - h) : + m1)); + } + + /** + * Functions for converting colors to hex-rgb representations. + * @private + */ + var color = { + /** + * @param {number} r Red channel [0, 255] + * @param {number} g Green channel [0, 255] + * @param {number} b Blue channel [0, 255] + */ + rgb: function (r, g, b) { + return "#" + decToHex(r) + decToHex(g) + decToHex(b); + }, + /** + * @param h Hue [0, 1] + * @param s Saturation [0, 1] + * @param l Lightness [0, 1] + */ + hsl: function (h, s, l) { + // Based on http://www.w3.org/TR/2011/REC-css3-color-20110607/#hsl-color + if (s == 0) { + var partialHex = decToHex(l * 255); + return "#" + partialHex + partialHex + partialHex; + } + else { + var m2 = l <= 0.5 ? l * (s + 1) : l + s - l * s, + m1 = l * 2 - m2; + return "#" + + hueToRgb(m1, m2, h * 6 + 2) + + hueToRgb(m1, m2, h * 6) + + hueToRgb(m1, m2, h * 6 - 2); + } + }, + // This function will correct the lightness for the "dark" hues + correctedHsl: function (h, s, l) { + // The corrector specifies the perceived middle lightnesses for each hue + var correctors = [ 0.55, 0.5, 0.5, 0.46, 0.6, 0.55, 0.55 ], + corrector = correctors[(h * 6 + 0.5) | 0]; + + // Adjust the input lightness relative to the corrector + l = l < 0.5 ? l * corrector * 2 : corrector + (l - 0.5) * (1 - corrector) * 2; + + return color.hsl(h, s, l); + } + }; + + + + + /** + * Gets a set of identicon color candidates for a specified hue and config. + */ + function colorTheme(hue, config) { + return [ + // Dark gray + color.hsl(0, 0, config.grayscaleLightness(0)), + // Mid color + color.correctedHsl(hue, config.saturation, config.colorLightness(0.5)), + // Light gray + color.hsl(0, 0, config.grayscaleLightness(1)), + // Light color + color.correctedHsl(hue, config.saturation, config.colorLightness(1)), + // Dark color + color.correctedHsl(hue, config.saturation, config.colorLightness(0)) + ]; + } + + + + + /** + * Draws an identicon to a specified renderer. + */ + function iconGenerator(renderer, hash, x, y, size, padding, config) { + var undefined; + + // Calculate padding + padding = (size * (padding === undefined ? 0.08 : padding)) | 0; + size -= padding * 2; + + if (!/^[0-9a-f]{11,}$/i.test(hash)) { + throw new Error("Invalid hash passed to Jdenticon."); + } + + var graphics = new Graphics(renderer); + + // Calculate cell size and ensure it is an integer + var cell = 0 | (size / 4); + + // Since the cell size is integer based, the actual icon will be slightly smaller than specified => center icon + x += 0 | (padding + size / 2 - cell * 2); + y += 0 | (padding + size / 2 - cell * 2); + + function renderShape(colorIndex, shapes, index, rotationIndex, positions) { + var r = rotationIndex ? parseInt(hash.charAt(rotationIndex), 16) : 0, + shape = shapes[parseInt(hash.charAt(index), 16) % shapes.length], + i; + + renderer.beginShape(availableColors[selectedColorIndexes[colorIndex]]); + + for (i = 0; i < positions.length; i++) { + graphics._transform = new Transform(x + positions[i][0] * cell, y + positions[i][1] * cell, cell, r++ % 4); + shape(graphics, cell, i); + } + + renderer.endShape(); + } + + // AVAILABLE COLORS + var hue = parseInt(hash.substr(-7), 16) / 0xfffffff, + + // Available colors for this icon + availableColors = colorTheme(hue, config), + + // The index of the selected colors + selectedColorIndexes = [], + index; + + function isDuplicate(values) { + if (values.indexOf(index) >= 0) { + for (var i = 0; i < values.length; i++) { + if (selectedColorIndexes.indexOf(values[i]) >= 0) { + return true; + } + } + } + } + + for (var i = 0; i < 3; i++) { + index = parseInt(hash.charAt(8 + i), 16) % availableColors.length; + if (isDuplicate([0, 4]) || // Disallow dark gray and dark color combo + isDuplicate([2, 3])) { // Disallow light gray and light color combo + index = 1; + } + selectedColorIndexes.push(index); + } + + // ACTUAL RENDERING + // Sides + renderShape(0, shapes.outer, 2, 3, [[1, 0], [2, 0], [2, 3], [1, 3], [0, 1], [3, 1], [3, 2], [0, 2]]); + // Corners + renderShape(1, shapes.outer, 4, 5, [[0, 0], [3, 0], [3, 3], [0, 3]]); + // Center + renderShape(2, shapes.center, 1, null, [[1, 1], [2, 1], [2, 2], [1, 2]]); + }; + + + + /** + * Represents an SVG path element. + * @private + * @constructor + */ + function SvgPath() { + /** + * This property holds the data string (path.d) of the SVG path. + */ + this.dataString = ""; + } + SvgPath.prototype = { + /** + * Adds a polygon with the current fill color to the SVG path. + * @param points An array of Point objects. + */ + addPolygon: function (points) { + var dataString = "M" + points[0].x + " " + points[0].y; + for (var i = 1; i < points.length; i++) { + dataString += "L" + points[i].x + " " + points[i].y; + } + this.dataString += dataString + "Z"; + }, + /** + * Adds a circle with the current fill color to the SVG path. + * @param {Point} point The upper left corner of the circle bounding box. + * @param {number} diameter The diameter of the circle. + * @param {boolean} counterClockwise True if the circle is drawn counter-clockwise (will result in a hole if rendered on a clockwise path). + */ + addCircle: function (point, diameter, counterClockwise) { + var sweepFlag = counterClockwise ? 0 : 1, + radius = diameter / 2; + this.dataString += + "M" + (point.x) + " " + (point.y + radius) + + "a" + radius + "," + radius + " 0 1," + sweepFlag + " " + diameter + ",0" + + "a" + radius + "," + radius + " 0 1," + sweepFlag + " " + (-diameter) + ",0"; + } + }; + + + + /** + * Renderer producing SVG output. + * @private + * @constructor + */ + function SvgRenderer(width, height) { + this._pathsByColor = { }; + this._size = { w: width, h: height }; + } + SvgRenderer.prototype = { + /** + * Marks the beginning of a new shape of the specified color. Should be ended with a call to endShape. + * @param {string} color Fill color on format #xxxxxx. + */ + beginShape: function (color) { + this._path = this._pathsByColor[color] || (this._pathsByColor[color] = new SvgPath()); + }, + /** + * Marks the end of the currently drawn shape. + */ + endShape: function () { }, + /** + * Adds a polygon with the current fill color to the SVG. + * @param points An array of Point objects. + */ + addPolygon: function (points) { + this._path.addPolygon(points); + }, + /** + * Adds a circle with the current fill color to the SVG. + * @param {Point} point The upper left corner of the circle bounding box. + * @param {number} diameter The diameter of the circle. + * @param {boolean} counterClockwise True if the circle is drawn counter-clockwise (will result in a hole if rendered on a clockwise path). + */ + addCircle: function (point, diameter, counterClockwise) { + this._path.addCircle(point, diameter, counterClockwise); + }, + /** + * Gets the rendered image as an SVG string. + * @param {boolean=} fragment If true, the container svg element is not included in the result. + */ + toSvg: function (fragment) { + var svg = fragment ? '' : + ''; + + for (var color in this._pathsByColor) { + svg += ''; + } + + return fragment ? svg : + svg + ''; + } + }; + + + + /** + * Renderer redirecting drawing commands to a canvas context. + * @private + * @constructor + */ + function CanvasRenderer(ctx, width, height) { + this._ctx = ctx; + ctx.clearRect(0, 0, width, height); + } + CanvasRenderer.prototype = { + /** + * Marks the beginning of a new shape of the specified color. Should be ended with a call to endShape. + * @param {string} color Fill color on format #xxxxxx. + */ + beginShape: function (color) { + this._ctx.fillStyle = color; + this._ctx.beginPath(); + }, + /** + * Marks the end of the currently drawn shape. This causes the queued paths to be rendered on the canvas. + */ + endShape: function () { + this._ctx.fill(); + }, + /** + * Adds a polygon to the rendering queue. + * @param points An array of Point objects. + */ + addPolygon: function (points) { + var ctx = this._ctx, i; + ctx.moveTo(points[0].x, points[0].y); + for (i = 1; i < points.length; i++) { + ctx.lineTo(points[i].x, points[i].y); + } + ctx.closePath(); + }, + /** + * Adds a circle to the rendering queue. + * @param {Point} point The upper left corner of the circle bounding box. + * @param {number} diameter The diameter of the circle. + * @param {boolean} counterClockwise True if the circle is drawn counter-clockwise (will result in a hole if rendered on a clockwise path). + */ + addCircle: function (point, diameter, counterClockwise) { + var ctx = this._ctx, + radius = diameter / 2; + ctx.arc(point.x + radius, point.y + radius, radius, 0, Math.PI * 2, counterClockwise); + ctx.closePath(); + } + }; + + + + + + + var /** @const */ + HASH_ATTRIBUTE = "data-jdenticon-hash", + supportsQuerySelectorAll = "document" in global && "querySelectorAll" in document; + + /** + * Gets the normalized current Jdenticon color configuration. Missing fields have default values. + */ + function getCurrentConfig() { + var configObject = jdenticon["config"] || global["jdenticon_config"] || { }, + lightnessConfig = configObject["lightness"] || { }, + saturation = configObject["saturation"]; + + /** + * Creates a lightness range. + */ + function lightness(configName, defaultMin, defaultMax) { + var range = lightnessConfig[configName] instanceof Array ? lightnessConfig[configName] : [defaultMin, defaultMax]; + + /** + * Gets a lightness relative the specified value in the specified lightness range. + */ + return function (value) { + value = range[0] + value * (range[1] - range[0]); + return value < 0 ? 0 : value > 1 ? 1 : value; + }; + } + + return { + saturation: typeof saturation == "number" ? saturation : 0.5, + colorLightness: lightness("color", 0.4, 0.8), + grayscaleLightness: lightness("grayscale", 0.3, 0.9) + } + } + + /** + * Updates the identicon in the specified canvas or svg elements. + * @param {string=} hash Optional hash to be rendered. If not specified, the hash specified by the data-jdenticon-hash is used. + * @param {number=} padding Optional padding in percents. Extra padding might be added to center the rendered identicon. + */ + function update(el, hash, padding) { + if (typeof(el) === "string") { + if (supportsQuerySelectorAll) { + var elements = document.querySelectorAll(el); + for (var i = 0; i < elements.length; i++) { + update(elements[i], hash, padding); + } + } + return; + } + if (!el || !el["tagName"]) { + // No element found + return; + } + hash = hash || el.getAttribute(HASH_ATTRIBUTE); + if (!hash) { + // No hash specified + return; + } + + var isSvg = el["tagName"].toLowerCase() == "svg", + isCanvas = el["tagName"].toLowerCase() == "canvas"; + + // Ensure we have a supported element + if (!isSvg && !(isCanvas && "getContext" in el)) { + return; + } + + var width = Number(el.getAttribute("width")) || el.clientWidth || 0, + height = Number(el.getAttribute("height")) || el.clientHeight || 0, + renderer = isSvg ? new SvgRenderer(width, height) : new CanvasRenderer(el.getContext("2d"), width, height), + size = Math.min(width, height); + + // Draw icon + iconGenerator(renderer, hash, 0, 0, size, padding, getCurrentConfig()); + + // SVG needs postprocessing + if (isSvg) { + // Parse svg to a temporary span element. + // Simply using innerHTML does unfortunately not work on IE. + var wrapper = document.createElement("span"); + wrapper.innerHTML = renderer.toSvg(false); + + // Then replace the content of the target element with the parsed svg. + while (el.firstChild) { + el.removeChild(el.firstChild); + } + var newNodes = wrapper.firstChild.childNodes; + while (newNodes.length) { + el.appendChild(newNodes[0]); + } + + // Set viewBox attribute to ensure the svg scales nicely. + el.setAttribute("viewBox", "0 0 " + width + " " + height); + } + } + + /** + * Draws an identicon to a context. + */ + function drawIcon(ctx, hash, size) { + if (!ctx) { + throw new Error("No canvas specified."); + } + + var renderer = new CanvasRenderer(ctx, size, size); + iconGenerator(renderer, hash, 0, 0, size, 0, getCurrentConfig()); + } + + /** + * Draws an identicon to a context. + * @param {number=} padding Optional padding in percents. Extra padding might be added to center the rendered identicon. + */ + function toSvg(hash, size, padding) { + var renderer = new SvgRenderer(size, size); + iconGenerator(renderer, hash, 0, 0, size, padding, getCurrentConfig()); + return renderer.toSvg(); + } + + /** + * Updates all canvas elements with the data-jdenticon-hash attribute. + */ + function jdenticon() { + if (supportsQuerySelectorAll) { + update("svg[" + HASH_ATTRIBUTE + "],canvas[" + HASH_ATTRIBUTE + "]"); + } + } + + // Public API + jdenticon["drawIcon"] = drawIcon; + jdenticon["toSvg"] = toSvg; + jdenticon["update"] = update; + jdenticon["version"] = "1.4.0"; + + // Basic jQuery plugin + if (jQuery) { + jQuery["fn"]["jdenticon"] = function (hash, padding) { + this["each"](function (index, el) { + update(el, hash, padding); + }); + return this; + }; + } + + // Schedule to render all identicons on the page once it has been loaded. + if (typeof setTimeout === "function") { + setTimeout(jdenticon, 0); + } + + return jdenticon; + +}); \ No newline at end of file diff --git a/build/js/vendor/jdenticon-1.4.0.min.js b/build/js/vendor/jdenticon-1.4.0.min.js new file mode 100644 index 0000000..86dd1ea --- /dev/null +++ b/build/js/vendor/jdenticon-1.4.0.min.js @@ -0,0 +1,13 @@ +// Jdenticon 1.4.0 | jdenticon.com | zlib licensed | (c) 2014-2016 Daniel Mester Pirttijärvi +(function(l,h,k){var m=k(l,l.jQuery);"undefined"!==typeof module&&"exports"in module?module.exports=m:"function"===typeof define&&define.amd?define([],function(){return m}):l[h]=m})(this,"jdenticon",function(l,h){function k(b,a){this.x=b;this.y=a}function m(b,a,c,d){this.o=b;this.s=a;this.f=c;this.l=d}function A(b){this.C=b;this.m=m.O}function n(b){b|=0;return 0>b?"00":16>b?"0"+b.toString(16):256>b?b.toString(16):"ff"}function r(b,a,c){c=0>c?c+6:6c?b+(a-b)*c:3>c?a:4>c?b+(a- +b)*(4-c):b))}function D(b,a){return[p.w(0,0,a.H(0)),p.v(b,a.A,a.u(.5)),p.w(0,0,a.H(1)),p.v(b,a.A,a.u(1)),p.v(b,a.A,a.u(0))]}function t(b,a,c,d,f){var e=0,u=0;function v(c,d,f,g,h){g=g?parseInt(a.charAt(g),16):0;d=d[parseInt(a.charAt(f),16)%d.length];b.F(p[n[c]]);for(c=0;cc;c++){h=parseInt(a.charAt(8+c),16)%p.length;if(g([0,4])||g([2,3]))h=1;n.push(h)}v(0,w.J,2,3,[[1,0],[2,0],[2,3],[1,3],[0,1],[3,1],[3,2],[0,2]]);v(1,w.J,4,5,[[0,0],[3,0],[3,3],[0,3]]);v(2,w.N,1,null,[[1,1],[2,1],[2,2],[1,2]])}function B(){this.i=""}function x(b,a){this.j={};this.f={M:b,I:a}}function y(b,a,c){this.h=b;b.clearRect(0,0,a,c)}function z(){function b(a,b,e){var d=c[a]instanceof +Array?c[a]:[b,e];return function(a){a=d[0]+a*(d[1]-d[0]);return 0>a?0:1a?1:8>a?2:0|.25*a;b.c(d,d,a-c-d,a-c-d)},function(b,a){var c=0|.15*a,d=0|.5*a;b.b(a-d-c,a-d-c,d)},function(b,a){var c=.1*a,d=4*c;b.c(0,0,a,a);b.a([d,d,a-c,d,d+(a-d-c)/2,a-c],!0)},function(b, +a){b.a([0,0,a,0,a,.7*a,.4*a,.4*a,.7*a,a,0,a])},function(b,a){b.g(a/2,a/2,a/2,a/2,3)},function(b,a){b.c(0,0,a,a/2);b.c(0,a/2,a/2,a/2);b.g(a/2,a/2,a/2,a/2,1)},function(b,a){var c=.14*a,c=8>a?c:0|c,d=4>a?1:6>a?2:0|.35*a;b.c(0,0,a,a);b.c(d,d,a-d-c,a-d-c,!0)},function(b,a){var c=.12*a,d=3*c;b.c(0,0,a,a);b.b(d,d,a-c-d,!0)},function(b,a){b.g(a/2,a/2,a/2,a/2,3)},function(b,a){var c=.25*a;b.c(0,0,a,a);b.D(c,c,a-c,a-c,!0)},function(b,a,c){var d=.4*a;c||b.b(d,d,1.2*a)}],J:[function(b,a){b.g(0,0,a,a,0)},function(b, +a){b.g(0,a/2,a,a/2,0)},function(b,a){b.D(0,0,a,a)},function(b,a){var c=a/6;b.b(c,c,a-2*c)}]},p={P:function(b,a,c){return"#"+n(b)+n(a)+n(c)},w:function(b,a,c){if(0==a)return b=n(255*c),"#"+b+b+b;a=.5>=c?c*(a+1):c+a-c*a;c=2*c-a;return"#"+r(c,a,6*b+2)+r(c,a,6*b)+r(c,a,6*b-2)},v:function(b,a,c){var d=[.55,.5,.5,.46,.6,.55,.55][6*b+.5|0];return p.w(b,a,.5>c?c*d*2:d+(c-.5)*(1-d)*2)}};B.prototype={a:function(b){for(var a="M"+b[0].x+" "+b[0].y,c=1;c',c;for(c in this.j)a+='';return b?a:a+""}};y.prototype={F:function(b){this.h.fillStyle=b;this.h.beginPath()},G:function(){this.h.fill()},a:function(b){var a=this.h,c;a.moveTo(b[0].x,b[0].y);for(c=1;c=7.9.0" + }, + "scripts": { + "test": "./node_modules/karma/bin/karma start ./karma.conf.js --no-auto-watch --single-run", + "test:watch": "./node_modules/karma/bin/karma start ./karma.conf.js", + "clean": "rm -f ./build/js/*.* && rm -f ./build/index.html", + "start": "http-server build", + "postinstall": "npm run build", + "build": "if-env NODE_ENV=production && npm run build:prod || npm run build:dev", + "build:dev": "npm run clean && webpack --hide-modules && NODE_ENV=development webpack-dev-server", + "build:prod": "npm run clean && NODE_ENV=production webpack", + "test:e2e": "node ./selenium-download.js && ./node_modules/nightwatch/bin/nightwatch" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/chb/patient-browser.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/chb/patient-browser/issues" + }, + "homepage": "https://github.com/chb/patient-browser#readme", + "dependencies": { + "autoprefixer": "^6.7.7", + "babel": "^6.23.0", + "babel-core": "^6.24.0", + "babel-eslint": "^7.2.1", + "babel-loader": "^6.4.1", + "babel-preset-es2015": "^6.24.0", + "babel-preset-react": "^6.23.0", + "babel-preset-stage-0": "^6.22.0", + "css-loader": "^0.28.0", + "eslint": "^3.19.0", + "eslint-plugin-react": "^6.10.3", + "history": "^4.6.1", + "html-webpack-plugin": "^2.28.0", + "http-server": "^0.10.0", + "if-env": "^1.0.0", + "jquery": "^3.2.1", + "json5": "^0.5.1", + "less": "^2.7.2", + "less-loader": "^4.0.3", + "moment": "^2.18.1", + "postcss-loader": "^1.3.3", + "react": "^15.4.2", + "react-dom": "^15.4.2", + "react-redux": "^5.0.3", + "react-router": "^4.0.0", + "react-router-dom": "^4.0.0", + "react-with-addons": "0.0.1", + "redux": "^3.6.0", + "redux-actions": "^2.0.1", + "redux-thunk": "^2.2.0", + "redux-thunk-actions": "^1.1.6", + "style-loader": "^0.16.1", + "webpack": "^2.3.3", + "webpack-bundle-analyzer": "^2.3.1" + }, + "browserslist": [ + "> 5%", + "last 2 versions", + "ie 10" + ], + "devDependencies": { + "chai": "^3.5.0", + "json5-loader": "^1.0.1", + "karma": "^1.6.0", + "karma-chai": "^0.1.0", + "karma-mocha": "^1.3.0", + "karma-mocha-reporter": "^2.2.3", + "karma-phantomjs-launcher": "^1.0.4", + "karma-webpack": "^2.0.3", + "mocha": "^3.2.0", + "nightwatch": "^0.9.16", + "react-addons-test-utils": "^15.4.2", + "react-hot-loader": "^1.3.1", + "selenium-download": "^2.0.10", + "webpack-dev-server": "^2.4.2" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..ba9c319 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,3 @@ +module.exports = { + use: [ require('autoprefixer') ] +} \ No newline at end of file diff --git a/selenium-download.js b/selenium-download.js new file mode 100644 index 0000000..df3e8df --- /dev/null +++ b/selenium-download.js @@ -0,0 +1,6 @@ +var selenium = require('selenium-download'); +selenium.ensure('./tests/e2e/bin', function(error) { + if (error) { + console.error(error.stack); + } +}); \ No newline at end of file diff --git a/src/components/AgeSelector/AgeSelector.less b/src/components/AgeSelector/AgeSelector.less new file mode 100644 index 0000000..b397322 --- /dev/null +++ b/src/components/AgeSelector/AgeSelector.less @@ -0,0 +1,124 @@ +@import "../../less/variables"; + +.age-widget-wrap { + display : flex; + flex-direction: row; + flex-wrap : nowrap; + white-space : nowrap; + padding : 0; + position : relative; + font-size : 12px; + + > * { + display : flex; + flex-grow : 1; + flex-shrink: 1; + margin : 0; + height : 100%; + font-size : 13px; + } + + select { + border : 1px solid transparent; + border-radius: 4px; + height : 100%; + background : transparent; + } + + &.custom > select { + flex-grow : 0; + min-width : 0; + flex-basis: 180px; + } + + input { + height: 100%; + border: 1px solid transparent; + } + + // &.custom { + // padding-right: 13px * 5 - 1; + // } + + // button { + // position : absolute; + // z-index : 2; + // top : -1px; + // right : -1px; + // bottom : -1px; + // height : auto; + // line-height : 1; + // border-radius: 0 4px 4px 0; + // width : 5em; + // min-width : 5em; + // text-align : center; + // display : block; + // } +} + +.age-widget { + display : flex; + flex-basis : 30%; + min-width : 0; + overflow : hidden; + align-items: center;; + box-shadow : 2px 1px 2px -2px #BBB inset; + + &:last-of-type { + border-radius: 0 3px 3px 0; + } + + span { + display : flex; + white-space : nowrap; + flex-basis : 7ex; + flex-shrink : 0; + flex-grow : 0; + margin : 0 1ex 0 0; + flex-direction: column; + align-content : flex-end; + background : rgba(0, 0, 0, 0.06); + box-shadow : 1px 0 0 0 rgba(0, 0, 0, 0.1), 1px 0 2px 0px rgba(0, 0, 0, 0.1); + line-height : 2.1em; + padding : 0 1ex; + text-align : right; + height : 100%; + font-weight : 500; + } + + input { + flex-shrink : 1; + flex-grow : 2; + flex-basis : 30px; + display : flex; + margin : 0; + background : transparent; + min-width : 32px; + border-right: 1px dotted rgba(0, 0, 0, 0.2); + } + + select { + display : flex; + flex-shrink: 1; + flex-grow : 1; + flex-basis : 6em; + min-width : 6em; + } + + &:hover { + box-shadow: 0 0 0 1px @color-bg-selection; + background: mix(@color-bg-selection, #FFF, 10%); + } + + &.invalid { + background: rgba(255, 0, 0, 0.1); + background: rgba(255, 0, 0, 0.03) repeating-linear-gradient( + -45deg, + rgba(255, 50, 0, 0), + rgba(255, 50, 0, 0) 2.5px, + rgba(255, 0, 0, 0.2) 3px, + rgba(255, 50, 0, 0) 3.5px + ); + background-size: 40px 40px; + } +} diff --git a/src/components/AgeSelector/__tests__/AgeSelector.js b/src/components/AgeSelector/__tests__/AgeSelector.js new file mode 100644 index 0000000..d0db17c --- /dev/null +++ b/src/components/AgeSelector/__tests__/AgeSelector.js @@ -0,0 +1,16 @@ +/* global describe, it, chai */ +import AgeSelector from ".." +import ReactTestUtils from "react-addons-test-utils" +import React from "react" + +const expect = chai.expect + + + +describe ("AgeSelector", () => { + it ("has the default prop values", () => { + let widget = ReactTestUtils.renderIntoDocument() + }) + it ("calls onChange for min.value") + it ("calls onChange for min.units") +}) \ No newline at end of file diff --git a/src/components/AgeSelector/index.js b/src/components/AgeSelector/index.js new file mode 100644 index 0000000..60c9d1b --- /dev/null +++ b/src/components/AgeSelector/index.js @@ -0,0 +1,252 @@ +import React from "react" +import moment from "moment" +import "./AgeSelector.less" + +export default class AgeSelector extends React.Component +{ + static propTypes = { + min: React.PropTypes.shape({ + value: React.PropTypes.number, + units: React.PropTypes.oneOf(["years", "months", "days"]) + }), + max: React.PropTypes.shape({ + value: React.PropTypes.number, + units: React.PropTypes.oneOf(["years", "months", "days"]) + }), + group: React.PropTypes.string, + onMinChange : React.PropTypes.func, + onMaxChange : React.PropTypes.func, + onGroupChange: React.PropTypes.func, + update : React.PropTypes.func + }; + + static defaultProps = { + min: { + value: 0, + units: "years" + }, + max: { + value: 100, + units: "years" + }, + group: "", + update: () => alert("No update function provided!") + }; + + constructor(...args) { + super(...args) + this.onChangeMinValue = this.onChangeMinValue.bind(this) + this.onChangeMinUnits = this.onChangeMinUnits.bind(this) + this.onChangeMaxValue = this.onChangeMaxValue.bind(this) + this.onChangeMaxUnits = this.onChangeMaxUnits.bind(this) + this.onGroupChange = this.onGroupChange .bind(this) + this.state = this._propsToState(this.props); + } + + _propsToState(props) { + let state = {} + if (props.min && typeof props.min == "object") { + if (props.min.hasOwnProperty("value")) { + state.minValue = isNaN(props.min.value) ? "" : props.min.value; + } + if (props.min.hasOwnProperty("units")) { + state.minUnits = props.min.units; + } + } + if (props.max && typeof props.max == "object") { + if (props.max.hasOwnProperty("value")) { + state.maxValue = isNaN(props.max.value) ? "" : props.max.value; + } + if (props.max.hasOwnProperty("units")) { + state.maxUnits = props.max.units; + } + } + + return state; + } + + componentWillReceiveProps(newProps) { + let newState = this._propsToState(newProps); + if (Object.keys(newState).length) { + this.setState(newState); + } + } + + // Event handlers ---------------------------------------------------------- + + /** + * When the numeric value of the min-age changes + * @param {Event} e + */ + onChangeMinValue(e) { + if (this.props.onMinChange) { + let min = { + value: e.target.valueAsNumber, + units: this.props.min.units + } + + if (this.canSet(min, this.props.max)) { + this.props.onMinChange(min) + } + else { + this.setState(this._propsToState({ min })); + } + } + } + + /** + * When the units of the min-age change + * @param {Event} e + */ + onChangeMinUnits(e) { + if (this.props.onMinChange) { + let min = { + value: this.props.min.value, + units: e.target.value + }; + if (this.canSet(min, this.props.max)) { + this.props.onMinChange(min) + } + else { + this.setState(this._propsToState({ min })); + } + } + } + + /** + * When the numeric value of the max-age changes + * @param {Event} e + */ + onChangeMaxValue(e) { + if (this.props.onMaxChange) { + let max = { + value: e.target.valueAsNumber, + units: this.props.max.units + }; + if (this.canSet(this.props.min, max)) { + this.props.onMaxChange(max) + } + else { + this.setState(this._propsToState({ max })); + } + } + } + + /** + * When the units of the max-age change + * @param {Event} e + */ + onChangeMaxUnits(e) { + if (this.props.onMaxChange) { + let max = { + value: this.props.max.value, + units: e.target.value + }; + if (this.canSet(this.props.min, max)) { + this.props.onMaxChange(max) + } + else { + this.setState(this._propsToState({ max })); + } + } + } + + /** + * When the selected age group changes + * @param {Event} e + */ + onGroupChange(e) { + if (this.props.onGroupChange) { + this.props.onGroupChange(e.target.value) + } + } + + // Validators and helper methods ------------------------------------------- + + /** + * Given two dates (as value and units) returns true if min is before max. + * This is used to validate inputs and prevent the user from entering + * invalid ranges + * @param {Object} min The min date as { value: number, units: string } + * @param {Object} max The max date as { value: number, units: string } + * @returns {Boolean} + */ + canSet(min, max) { + let maxDuration = moment.duration(max.value, max.units); + let minDuration = moment.duration(min.value, min.units); + return minDuration < maxDuration; + } + + // Rendering methods ------------------------------------------------------- + + render() { + let valid = this.canSet({ + value: this.state.minValue, + units: this.state.minUnits + }, { + value: this.state.maxValue, + units: this.state.maxUnits + }); + return ( +
+ + { + this.props.group == "**custom**" ? +
+ Min: + +
: null + } + { + this.props.group == "**custom**" ? +
+ Max: + +
: null + } +
+ ) + } +} \ No newline at end of file diff --git a/src/components/Alert/index.js b/src/components/Alert/index.js new file mode 100644 index 0000000..2f8e15e --- /dev/null +++ b/src/components/Alert/index.js @@ -0,0 +1,59 @@ +import React from "react"; + +const TYPES = React.PropTypes; + +const ICONS = { + info : "fa fa-info-circle", + warning: "fa fa-exclamation-circle", + danger : "fa fa-exclamation-triangle", + success: "fa fa-thumbs-up" +}; + +export default class Alert extends React.Component +{ + static propTypes = { + type : TYPES.oneOf(["info", "warning", "danger", "success"]), + close : TYPES.bool, + icon : TYPES.bool, + children: TYPES.any + }; + + static defaultProps = { + type : "info", + close: true, + icon : true + }; + + render() { + return ( +
+
+
+
+
+ { + this.props.close ? + : + null + } + { + this.props.icon ? + : + null + } + { this.props.children } +
+
+
+
+
+ ) + } +} \ No newline at end of file diff --git a/src/components/App/App.less b/src/components/App/App.less new file mode 100644 index 0000000..ad3b452 --- /dev/null +++ b/src/components/App/App.less @@ -0,0 +1,190 @@ +@import "../../less/variables"; + +* { + box-sizing: border-box; +} + +html { + height : 100%; + display: block; +} + +body { + background : linear-gradient(@color-bg-body, #FFF); + color : @color-fg-body; + margin : 0; + padding : 0; + overflow : hidden; + height : 100%; + font-family: "Helvetica Neue", Arial, sans-serif; + font-size : 14px; + line-height: normal; +} + +a { + text-decoration: none; + color : @color-bg-selection; + + &:disabled, &[disabled] { + pointer-events: none; + opacity : 0.4 !important; + } +} + +hr { + border-top : 1px solid rgba(0, 0, 0, 0.1); + border-bottom: 1px solid rgba(255, 255, 255, 0.9); +} + +*:focus { + outline: none !important; +} + +.tab-content { + background: linear-gradient(#FFF, transparent); + border: 1px solid #DDD; + border-top: 0; + padding: 15px 15px 0; + border-radius: 0 0 5px 5px; +} + +#overlay { + position : fixed; + top : 0; + right : 0; + bottom : 0; + left : 0; + width : 100%; + height : 100%; + background: rgba(150, 150, 150, 0.1); + z-index : 1000000000; +} + +#main { + height : 100%; +} + +.app { + display : flex; + flex-direction: column; + flex-shrink : 0; + height : 100%; +} + +.page { + display : flex; + position : fixed; + top : 0; + right : 0; + bottom : 0; + left : 0; + min-width : 500px; +} + +.page { + display: flex; +} + +.patient-search-loading { + display : flex; + flex-grow : 1; + align-content: center; + align-items : center; + align-self : center; + + .fa { + margin: 1ex; + } +} + + +.spin { + animation: spin 2s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(359deg); + } +} + +.page { + display: flex; + flex-grow: 1; + flex-direction: column; + height: 100%; +} + +//////////////////////////////////////////////////////////////////////////////// +// .pageSwap-enter { +// transform: translate3d(10%, 0, 0); +// opacity: 0.01; +// transition: none; +// position: relative; +// z-index: 2; +// // background: orange; +// display: flex; +// flex-grow: 1; +// flex-direction: column; +// } + +// .pageSwap-enter.pageSwap-enter-active { +// transform: translate3d(0, 0, 0); +// opacity: 1; +// transition: all ease-out 6000ms; +// z-index: 1; +// } + + +// .pageSwap-leave { +// transform: translate3d(0, 0, 0); +// opacity: 1; +// z-index: 1; +// } + +// .pageSwap-leave.pageSwap-leave-active { +// opacity: 0.01; +// transition: all ease-out 600ms; +// z-index: 1; +// } + +.pre { + white-space: pre; + font-family: monospace; + font-size : 12px; + display : block; +} + +.search-match { + background : #FFCF39; + box-shadow : 0 0 1px rgba(255, 255, 255, 0.8) inset, 0 0 1px rgba(0, 0, 0, 0.99) !important; + border-radius: 2px; + text-shadow : 0 0 1px #FFF !important; + color : #000 !important; +} + +.small.pull-left, +.small.pull-right, +small.pull-left, +small.pull-right { + margin-top: 0.5ex; +} + +label[disabled] { + color: rgba(0, 0, 0, 0.4); + cursor: not-allowed; + user-select: none; +} + +.deceased-label { + vertical-align: baseline; + color: #e45f00; + font-size: 11px; + padding: 0px 4px 1px; + border-radius: 3px; + border: 1px solid #ff6a00; + box-shadow: 0 1px 4px 0px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(255, 255, 255, 0.31) inset; +} diff --git a/src/components/App/index.js b/src/components/App/index.js new file mode 100644 index 0000000..129fea7 --- /dev/null +++ b/src/components/App/index.js @@ -0,0 +1,131 @@ +import React from "react" +import $ from "jquery" +import JSON5 from "json5" +import { connect } from "react-redux" +import Loader from "../Loader" +import ErrorMessage from "../ErrorMessage" +import { fetch, setLimit } from "../../redux/query" +import { merge } from "../../redux/settings" +import { parseQueryString } from "../../lib" +import "./App.less" + +const OWNER = window.opener || (window.parent === self ? null : window.parent); +const DEFAULT_CONFIG = "stu3-open-sandbox"; + + + +export class App extends React.Component +{ + static propTypes = { + children : React.PropTypes.any, + settings : React.PropTypes.object.isRequired, + dispatch : React.PropTypes.func.isRequired + }; + + constructor(...args) { + super(...args) + this.state = { + error: null + }; + } + + handleUiBlocking() { + let runningRequests = 0, hideDelay; + + const handle = () => { + if (hideDelay) { + clearTimeout(hideDelay) + } + if (runningRequests > 0) { + $("#overlay").show(); + } + else { + hideDelay = setTimeout(() => { + $("#overlay").hide(); + }, 200) + } + }; + + $(document).ajaxStart(() => { + runningRequests++; + handle(); + }); + + $(document).ajaxComplete(() => { + runningRequests = Math.max(--runningRequests, 0); + handle(); + }) + } + + componentDidMount() { + + this.handleUiBlocking(); + + let { config, ...params } = parseQueryString(window.location.search); + + let settings = {}; + $.ajax({ + url : `/config/${config || DEFAULT_CONFIG}.json5`, + dataType: "text", + cache : false + }).then( + json => { + json = JSON5.parse(json); + $.ajaxSetup({ timeout: json.timeout || 20000 }); + settings = { ...json, ...params } + }, + errorXHR => { + console.warn("Loading custom config: " + errorXHR.statusText); + } + ).always(() => { + let settingReceived = false; + + const onMessage = e => { + if (e.data.type == 'config' && + e.data.data && + typeof e.data.data == "object") + { + settingReceived = true; + this.props.dispatch(merge(e.data.data)); + this.props.dispatch(fetch()); + } + }; + + this.props.dispatch(merge(settings)); + this.props.dispatch(setLimit(settings.patientsPerPage)); + + if (OWNER) { + + window.addEventListener("unload", function() { + OWNER.postMessage({ type: "close" }, "*") + }); + + window.addEventListener("message", onMessage); + + setTimeout(() => { + window.removeEventListener("message", onMessage); + if (!settingReceived) { + this.props.dispatch(fetch()); + } + }, 1000); + + OWNER.postMessage({ type: "ready" }, "*"); + } + else { + this.props.dispatch(fetch()); + } + }) + } + + render() { + if (this.state.error) { + return + } + if (!this.props.settings || !this.props.settings.loaded) { + return
+ } + return
{this.props.children}
+ } +} + +export default connect(state => ({...state}))(App) diff --git a/src/components/DialogFooter/DialogFooter.less b/src/components/DialogFooter/DialogFooter.less new file mode 100644 index 0000000..a5d66e9 --- /dev/null +++ b/src/components/DialogFooter/DialogFooter.less @@ -0,0 +1,71 @@ +.dialog-buttons { + + button { + min-width: 10em; + margin: 0 0 0 1em + } + + label { + margin: 2px 0; + display: flex; + align-items: center; + min-height: 30px; + } + + .btn-group { + display: inline-flex; + flex: 0 1 18em; + line-height: 1; + flex-direction: row; + box-shadow: 0 1px 1px #FFF; + margin: 0 6px; + // overflow: hidden; + + > .btn { + display: flex; + flex: 1 1 120px; + align-items: center; + align-content: center; + align-self: center; + flex-direction: column; + min-width: 4em; + margin: 0; + border: 1px solid #BBB; + padding: 4px 2ex; + background: linear-gradient(#F9F9F9, #E9E9E9); + font-weight: normal; + color: #666; + text-shadow: 0 1px 0 #FFF; + box-shadow: 0 1px 1px #FFF inset; + overflow: hidden; + + &:hover { + background: linear-gradient(#FFF, #EEE); + } + + &:active, &.active { + background: linear-gradient(#CCC, #DDD); + box-shadow: 0 1px 1px #BBB inset; + border-color: #999 #AAA #BBB; + } + + + .btn { + margin-left: -1px; + } + + &:first-child { + border-radius: 4px 0 0 4px; + } + &:last-child { + border-radius: 0 4px 4px 0; + } + } + } + + :disabled, [disabled], .disabled { + .btn-group { + opacity: 0.5; + pointer-events: none; + } + } +} \ No newline at end of file diff --git a/src/components/DialogFooter/index.js b/src/components/DialogFooter/index.js new file mode 100644 index 0000000..958b3b3 --- /dev/null +++ b/src/components/DialogFooter/index.js @@ -0,0 +1,190 @@ +import React from "react" +import { connect } from "react-redux" +import { showSelectedOnly } from "../../redux/settings" +import { setParam, fetch } from "../../redux/query" +import { setAll } from "../../redux/selection" +import "./DialogFooter.less" + +const IS_POPUP = window.opener && window.name; +const IS_FRAME = parent !== self; + +class SelectionUI extends React.Component { + + static propTypes = { + settings : React.PropTypes.object.isRequired, + selection : React.PropTypes.object.isRequired, + onToggleSelectionVisibility: React.PropTypes.func.isRequired, + onResetSelection: React.PropTypes.func.isRequired, + canShowSelected : React.PropTypes.bool + }; + + render() { + let len = Object.keys(this.props.selection).filter(key => !!this.props.selection[key]).length; + let viewClass = ["btn"]; + let resetClass = ["btn"]; + + if (len === 0) { + viewClass.push("disabled"); + resetClass.push("disabled"); + } + + if (this.props.settings.renderSelectedOnly) { + viewClass.push("active"); + } + + const hasSelection = this.props.canShowSelected && len > 0 + return ( + + ); + } +} + +export class DialogFooter extends React.Component +{ + static propTypes = { + selection : React.PropTypes.object.isRequired, + settings : React.PropTypes.object.isRequired, + showSelectedOnly: React.PropTypes.func.isRequired, + resetSelection : React.PropTypes.func.isRequired, + setParam : React.PropTypes.func.isRequired, + fetch : React.PropTypes.func.isRequired, + canShowSelected : React.PropTypes.bool + }; + + /** + * Generates and returns the object that will be sent back to the client + * when the OK button is clicked + */ + export() { + // debugger; + const selection = this.props.selection; + + switch (this.props.settings.outputMode) { + + case "id-array": // array of patient IDs + return Object.keys(selection).filter(k => !!selection[k]); + + case "transactions": // array of JSON transactions objects for each patient + return Object.keys(selection).filter(k => !!selection[k]).map( + k => this.createPatientTransaction(selection[k]) + ); + + case "patients": // array of patient JSON objects + return Object.keys(selection).filter(k => !!selection[k]).map( + k => selection[k] + ); + + case "id-list": // comma-separated list of patient IDs (default) + return Object.keys(selection).filter(k => !!selection[k]).join(",") + } + } + + createPatientTransaction() { + throw new Error("No implemented"); + } + + showSelectedOnly(bOn) { + if (bOn) { + const selection = this.props.selection; + this.props.setParam({ + name : "_id", + value: Object.keys(selection).filter(k => !!selection[k]).join(",") + }) + } + else { + this.props.setParam({ + name : "_id", + value: undefined + }) + } + this.props.showSelectedOnly(bOn) + } + + renderDialogButtons() { + if (!IS_POPUP && !IS_FRAME) { + return null + } + + const OWNER = window.opener || window.parent + + return ( +
+ + +
+ ); + } + + render() { + let selection = this.props.selection; + selection = Object.keys(selection).filter(k => !!selection[k]) + return ( +
+
+ +
+ { this.renderDialogButtons() } +
+ ) + } +} + +export default connect( + state => ({ + selection: state.selection, + settings : state.settings, + query : state.query + }), + dispatch => ({ + showSelectedOnly: bValue => { + dispatch(showSelectedOnly(bValue)) + dispatch(fetch()) + }, + resetSelection : () => { + dispatch(setAll({})) + dispatch(showSelectedOnly(false)) + dispatch(setParam({ name: "_id", value: undefined })) + dispatch(fetch()) + }, + setParam: (name, value) => dispatch(setParam(name, value)), + fetch: () => dispatch(fetch()) + }) +)(DialogFooter) diff --git a/src/components/ErrorMessage/index.js b/src/components/ErrorMessage/index.js new file mode 100644 index 0000000..96e4403 --- /dev/null +++ b/src/components/ErrorMessage/index.js @@ -0,0 +1,25 @@ +import React from "react" +import { getErrorMessage } from "../../lib" +import Alert from "../Alert" + +export default class ErrorMessage extends React.Component +{ + static propTypes = { + error: React.PropTypes.oneOfType([ + React.PropTypes.string, + React.PropTypes.object + ]) + }; + + static defaultProps = { + error: "Unknown Error" + }; + + render() { + return ( + + { getErrorMessage(this.props.error) } + + ) + } +} \ No newline at end of file diff --git a/src/components/Fhir/CarePlan.js b/src/components/Fhir/CarePlan.js new file mode 100644 index 0000000..9b3723c --- /dev/null +++ b/src/components/Fhir/CarePlan.js @@ -0,0 +1,81 @@ +import React from "react" +import moment from "moment" +import Grid from "./Grid" +import { getPath } from "../../lib" + +export default class CarePlan extends React.Component +{ + static propTypes = { + resources: React.PropTypes.arrayOf(React.PropTypes.object) + }; + + render() + { + return ( + o.resource) } + title="CarePlan" + cols={[ + { + label: "Category", + render: rec => ( + + { getPath(rec, "category.0.coding.0.display") } + + ) + }, + { + label: "Reason", + render: rec => (rec.activity || []).map((a, i) => { + let reason = getPath(a, "detail.code.coding.0.display") || "" + return reason ? ( +
+ { reason } + - + { + getPath(a, "detail.status") || "no data" + } +
+ ) : "" + }) + }, + { + label: "Period", + render: rec => { + let from = getPath(rec, "period.start") || "" + let to = getPath(rec, "period.end" ) || "" + + if (from) from = moment(from).format("MM/DD/YYYY") + if (to) to = moment(to).format("MM/DD/YYYY") + return ( + + { + from ? + + from + { from } + : + null + } + { + to ? + + to + { to } + : + null + } + + ) + } + }, + { + label: "Status", + path : "status" + } + ]} + /> + ) + } +} + diff --git a/src/components/Fhir/ConditionList.js b/src/components/Fhir/ConditionList.js new file mode 100644 index 0000000..2cd88dd --- /dev/null +++ b/src/components/Fhir/ConditionList.js @@ -0,0 +1,83 @@ +import React from "react" +import moment from "moment" +import { CODE_SYSTEMS } from "../../lib/constants" +import Grid from "./Grid" + +export default class ConditionList extends React.Component +{ + static propTypes = { + resources: React.PropTypes.arrayOf(React.PropTypes.object) + }; + + render() + { + let recs = this.props.resources || [] + let length = recs.length; + return ( + o.resource)} + title={`${length} Condition${length === 1 ? "" : "s"}`} + cols={[ + { + label : "Name", + render: o => { + let name = "-"; + let code = "-"; + let system = ""; + + if (o.code) { + if (o.code.text) { + name = o.code.text; + } + if (Array.isArray(o.code.coding) && o.code.coding.length) { + let c = o.code.coding[0] + + system = c.system + for (let key in CODE_SYSTEMS) { + if (CODE_SYSTEMS[key].url === c.system) { + system = `(${key})`; + break; + } + } + + if (c.display) { + name = c.display + } + if (c.code) { + code = c.code + } + } + } + return ( +
+ { name } + + { code } {system} + +
+ ) + } + }, + { + label:
Clinical Status
, + render: o =>
{ o.clinicalStatus }
+ }, + { + label :
Verification Status
, + render: o =>
{ o.verificationStatus || "-" }
+ }, + { + label:
Onset Date
, + render : o => { + let onset = o.onsetDateTime || ""; + if (onset) { + onset = moment(onset).format("MM/DD/YYYY"); + } + return
{ onset || "-" }
+ } + } + ]} + /> + ) + } +} \ No newline at end of file diff --git a/src/components/Fhir/Encounter.js b/src/components/Fhir/Encounter.js new file mode 100644 index 0000000..b805f7f --- /dev/null +++ b/src/components/Fhir/Encounter.js @@ -0,0 +1,34 @@ +import React from "react" +import Grid from "./Grid" + +export default class Encounter extends React.Component +{ + static propTypes = { + resources: React.PropTypes.arrayOf(React.PropTypes.object) + }; + + render() + { + return ( + o.resource) } + title={ `Encounter${this.props.resources.length === 1 ? "" : "s"}` } + cols={[ + { + label: "Type", + path : "type.0.text" + }, + { + label: "Reason", + path : "reason.0.coding.0.display" + }, + { + label: "Class", + path : "class.code" + } + ]} + /> + ) + } +} + diff --git a/src/components/Fhir/Grid/index.js b/src/components/Fhir/Grid/index.js new file mode 100644 index 0000000..72f42b0 --- /dev/null +++ b/src/components/Fhir/Grid/index.js @@ -0,0 +1,97 @@ +import React from "react" +import { getPath } from "../../../lib" +import { connect } from "react-redux" + +/** + * Renders group of resources in a grid (table) where each component represents + * one row... + */ +export class Grid extends React.Component +{ + static propTypes = { + rows : React.PropTypes.arrayOf(React.PropTypes.object).isRequired, + cols : React.PropTypes.arrayOf(React.PropTypes.object).isRequired, + settings: React.PropTypes.object.isRequired, + title : React.PropTypes.string + }; + + + renderResource(res, i) + { + let url = `${this.props.settings.server.url}/${res.resourceType}/${res.id}`; + if (this.props.settings.fhirViewer.enabled) { + url = this.props.settings.fhirViewer.url + + (this.props.settings.fhirViewer.url.indexOf("?") > -1 ? "&" : "?") + + this.props.settings.fhirViewer.param + "=" + + encodeURIComponent(url); + } + + return ( + window.open(url, "_blank") } + style={{ cursor: "pointer" }} + > + { + this.props.cols.map((col, i) => { + let { render, path, cellProps } = col + cellProps = { ...cellProps, key: i } + if (typeof render == "function") { + return ( + + { render(res) } + + ) + } + return ( + + { getPath(res, path) || "-" } + + ) + }) + } + + ) + } + + render() + { + return ( +
+ { + this.props.title ? +
+ + { this.props.title } + +
: + null + } + + + + { + this.props.cols.map((col, i) => { + let { headerProps, label } = col + headerProps = { ...headerProps, key: i } + return ( + + ) + }) + } + + + + { this.props.rows.map(this.renderResource.bind(this)) } + +
+ { label || "" } +
+
+ ) + } +} + +export default connect(state => ({ + settings: state.settings +}))(Grid); diff --git a/src/components/Fhir/ImmunizationList.js b/src/components/Fhir/ImmunizationList.js new file mode 100644 index 0000000..eab4bb7 --- /dev/null +++ b/src/components/Fhir/ImmunizationList.js @@ -0,0 +1,38 @@ +import React from "react" +import moment from "moment" +import Grid from "./Grid" + +export default class ImmunizationList extends React.Component +{ + static propTypes = { + resources: React.PropTypes.arrayOf(React.PropTypes.object) + }; + + render() + { + return ( + o.resource) } + title="Immunizations" + cols={[ + { + label: "Type", + path: "vaccineCode.coding.0.display" + }, + { + label : "Status", + render: o => o.status || "-" + }, + { + label:
Date
, + render: o => ( +
+ { o.date ? moment(o.date).format("MM/DD/YYYY") : "-" } +
+ ) + } + ]} + /> + ); + } +} \ No newline at end of file diff --git a/src/components/Fhir/MedicationRequest.js b/src/components/Fhir/MedicationRequest.js new file mode 100644 index 0000000..149ab42 --- /dev/null +++ b/src/components/Fhir/MedicationRequest.js @@ -0,0 +1,34 @@ +import React from "react" +import Grid from "./Grid" + +export default class MedicationRequest extends React.Component +{ + static propTypes = { + resources: React.PropTypes.arrayOf(React.PropTypes.object) + }; + + render() + { + return ( + o.resource) } + title="Medication Requests" + cols={[ + { + label: "Name", + path : "medicationCodeableConcept.coding.0.display" + }, + { + label: "Code", + path : "medicationCodeableConcept.coding.0.code" + }, + { + label: "Status", + path : "status" + } + ]} + /> + ) + } +} + diff --git a/src/components/Fhir/Observation.js b/src/components/Fhir/Observation.js new file mode 100644 index 0000000..477854b --- /dev/null +++ b/src/components/Fhir/Observation.js @@ -0,0 +1,78 @@ +import React from "react" +import moment from "moment" +import { getPath } from "../../lib" +import Grid from "./Grid" + +export default class Observations extends React.Component +{ + static propTypes = { + resources: React.PropTypes.arrayOf(React.PropTypes.object) + }; + + renderBloodPressure(resource) { + let component = resource.component || [resource] + let out = [] + component.forEach(o => { + let value = getPath(o, "valueQuantity.value") + let units = getPath(o, "valueQuantity.unit") + if (units && (value || value === 0)) { + out.push( + getPath(o, "code.coding.0.display") + ": " + + value + " " + units + ) + } + }); + + return out.join(", ") + } + + render() + { + return ( + o.resource) } + title="Observations" + cols={[ + { + label : "Name", + render: o => { + let name = getPath(o, "code.coding.0.display") || + getPath(o, "code.text"); + return {name} + } + }, + { + label : "Value", + render: o => { + let value = getPath(o, "valueQuantity.value"); + let units = getPath(o, "valueQuantity.unit"); + + if (getPath(o, "code.coding.0.code") == "55284-4" && + getPath(o, "code.coding.0.system") == "http://loinc.org") { + return this.renderBloodPressure(o) + } + + if (!value && value !== 0) { + return getPath(o, "valueCodeableConcept.coding.0.display") + } + + if (!isNaN(parseFloat(value))) { + value = Math.round(value * 100) / 100; + } + + return {value} {units} + } + }, + { + label:
Date
, + render: o => { + let date = getPath(o, "effectiveDateTime"); + if (date) date = moment(date).format("MM/DD/YYYY"); + return
{ date || "-" }
+ } + } + ]} + /> + ) + } +} diff --git a/src/components/Fhir/ResourceList.js b/src/components/Fhir/ResourceList.js new file mode 100644 index 0000000..025d635 --- /dev/null +++ b/src/components/Fhir/ResourceList.js @@ -0,0 +1,55 @@ +import React from "react" +import moment from "moment" +import Grid from "./Grid" + +export default class ResourceList extends React.Component +{ + static propTypes = { + type : React.PropTypes.string, + resources: React.PropTypes.arrayOf(React.PropTypes.object) + }; + + render() + { + let recs = this.props.resources || [] + let length = recs.length; + return ( + o.resource)} + title={`${length} resource${length === 1 ? "" : "s"} of type ${this.props.type}`} + cols={[ + { + label : this.props.type + " ID", + render: o => ( + {o.id} + ) + }, + { + label: "Text", + render: o => { + if (o.text && o.text.div) { + return
+ } + return "" + } + }, + { + label: "Last Updated", + render: o => { + if (o.meta && o.meta.lastUpdated) { + let d = moment(o.meta.lastUpdated) + return ( + + { d.toNow(true) } ago + + ) + } + return "-" + } + } + ]} + /> + ) + } +} + diff --git a/src/components/Footer/Footer.less b/src/components/Footer/Footer.less new file mode 100644 index 0000000..d615021 --- /dev/null +++ b/src/components/Footer/Footer.less @@ -0,0 +1,40 @@ +.app-footer { + position : relative; + z-index : 3; + display : flex; + flex-grow : 0; + flex-shrink : 0; + justify-content: center; + align-items : center; + box-shadow : 0 -0.5px 0 rgba(0, 0, 0, 0.2), 0 0 5px 0 rgba(0, 0, 0, 0.25); + text-shadow : 0 1px 0 #FFF; + background : #F0F0F0; + white-space : nowrap; + + .row { + padding : 4px 0; + box-shadow: 0 -1px 0 0 rgba(0, 0, 0, 0.1) inset; + + + .row { + box-shadow: 0 -1px 0 0 rgba(0, 0, 0, 0.1) inset, + 0 1px 0 0 rgba(255, 255, 255, 0.9) inset; + } + + &:first-child .col-xs-6 { + padding: 6px 0px; + } + } + + a { + display : block; + padding : 5px 0px; + text-align : center; + border-radius: 4px; + border : 1px solid transparent; + + &:hover { + background: rgba(51, 122, 183, 0.15); + border: 1px solid rgba(51, 122, 183, 0.29); + } + } +} \ No newline at end of file diff --git a/src/components/Footer/index.js b/src/components/Footer/index.js new file mode 100644 index 0000000..f491bef --- /dev/null +++ b/src/components/Footer/index.js @@ -0,0 +1,77 @@ +import React from "react" +import { goNext, goPrev } from "../../redux/query" +import { getBundleURL } from "../../lib" +import DialogFooter from "../DialogFooter" +import "./Footer.less" + + +export default class Footer extends React.Component +{ + static propTypes = { + bundle : React.PropTypes.object, + query : React.PropTypes.object.isRequired, + dispatch : React.PropTypes.func.isRequired, + selection: React.PropTypes.object.isRequired, + canShowSelected : React.PropTypes.bool + }; + + render() { + let msg = this.props.query.error ? " Error! " : " Loading... "; + let bundle = this.props.bundle; + let hasPrev = bundle && getBundleURL(bundle, "previous"); + let hasNext = bundle && getBundleURL(bundle, "next"); + + if (bundle && !this.props.query.error) { + if (this.props.query.params._id) { + msg = "Showing the selected patients only" + } + else { + let len = bundle && bundle.entry ? bundle.entry.length : 0 + if (len) { + let startRec = +(this.props.query.offset || 0) + 1 + let endRec = startRec + len - 1; + + msg = ` patient ${startRec} to ${ endRec } ` + + if ("total" in bundle) { + msg += ` of ${bundle.total} ` + } + } + else { + msg = ` No Records! ` + } + } + } + + return ( + + ) + } +} diff --git a/src/components/Header/Header.less b/src/components/Header/Header.less new file mode 100644 index 0000000..1ee92ae --- /dev/null +++ b/src/components/Header/Header.less @@ -0,0 +1,79 @@ +@import "../../less/variables"; + +.app-header { + color : mix(@color-bg-selection, #000, 60%); + padding : 5px 0 0; + background : linear-gradient(#DFDFDF, #EEE 5px, #e2e2e2); + position : relative; + z-index : 3; + box-shadow : 0 0.5px 0 rgba(0, 0, 0, 0.3), 0 0 5px 0 rgba(0, 0, 0, 0.5); + text-shadow : 0 1px 0 #FFF; + display : flex; + flex-shrink : 0; + flex-direction : column; + + .nav.nav-tabs { + padding-left: 5px; + + a { + padding: 5px 15px; + } + } + + .tab-content { + background : linear-gradient(#FFF, #F6F6F6); + border : 0; + border-bottom: 1px solid #BBB; + padding : 5px 5px 0; + border-radius: 0; + font-size : small; + box-shadow : 0 2px 1px -1px rgba(0, 0, 0, 0.1) + } + + .badge { + text-shadow: 0 0 1px #FFF; + background: rgba(0, 0, 0, 0.08); + color: #444; + font-weight: 400; + box-shadow: 0 0 0.5px 0 rgba(0, 0, 0, 0.2) inset; + } + + .advanced-label { + margin: 5px 10px; + + input[type="checkbox"] { + margin: 0; + vertical-align: middle; + } + } + + .form-group { + margin-bottom: 5px; + } + + label, .alert { + margin-bottom: 3px; + } + + .alert { + padding: 5px 10px; + } + + .btn-submit { + margin: 4px 5px 0 auto; + padding: 3px 24px; + box-shadow: -20px 2.5px 0px -6px #e3e3e3, + 20px 2.5px 0px -6px #e3e3e3, + 0px 1px 0px 2px #e3e3e3, + 0 1.5px 0 2px rgba(0, 0, 0, 0.4), + 0 4px 5px 0 rgba(0, 0, 0, 0.4), + 0px 1px 1px -1px #FFF inset, + 0px -17px 0px -5px rgba(0, 0, 0, 0.05) inset; + border-radius: 30px; + text-shadow: 0 -1px 1px rgba(0, 0, 0, 0.25), 0 0 1px rgba(0, 0, 0, 0.5); + border-color: rgba(0, 0, 0, 0.5); + position : relative; + z-index : 100000000; + } +} + diff --git a/src/components/Header/index.js b/src/components/Header/index.js new file mode 100644 index 0000000..92a3468 --- /dev/null +++ b/src/components/Header/index.js @@ -0,0 +1,352 @@ + +import React from "react" +import "./Header.less" +import { + fetch, + setGender, + setAgeGroup, + setMinAge, + setMaxAge, + setConditions, + setTags, + setParam, + setQueryString, + setQueryType, + setSort +} from "../../redux/query" +import store from "../../redux" +import TagSelector from "../TagSelector" +import AgeSelector from "../AgeSelector" +import SortWidget from "../SortWidget" +import { + parseQueryString, + setHashParam +} from "../../lib" + +export default class Header extends React.Component +{ + static propTypes = { + settings : React.PropTypes.object.isRequired, + query : React.PropTypes.object.isRequired, + location : React.PropTypes.object.isRequired, + urlParams: React.PropTypes.object.isRequired + }; + + fetch(delay=500) { + if (this.props.settings.submitStrategy == "automatic") { + if (this.fetchDelay) { + clearTimeout(this.fetchDelay); + } + this.fetchDelay = setTimeout(() => { + store.dispatch(fetch()) + }, delay) + } + // else { + // store.dispatch(fetch()) + // } + } + + renderAdvancedTabContents() { + return ( +
+

+ In advanced mode + you provide a query string that is supposed to return + a list of patients. Enter that query below and we will + provide you with an UI to browse those patients and + select some of them. +

+
{ + e.preventDefault() + store.dispatch(fetch()) + }}> +
+ /Patient? + store.dispatch(setQueryString(e.target.value)) } + value={ this.props.query.queryString } + /> + + + +
+
+
+ ) + } + + renderDemographicsTabContents() { + return ( +
{ + e.preventDefault() + store.dispatch(fetch()) + }}> +
+
+
+ {/**/} +
+ Name: + { + store.dispatch(setParam({ + name : "name", + value: e.target.value + })) + this.fetch() + }} + /> +
+
+
+
+
+ {/**/} + +
+
+
+
+ {/**/} + { + store.dispatch(setMinAge(age)) + //if ("**custom**" != this.props.query.ageGroup) { + this.fetch() + //} + }} + onMaxChange={ age => { + store.dispatch(setMaxAge(age)) + //if ("**custom**" != this.props.query.ageGroup) { + this.fetch() + //} + }} + onGroupChange={ group => { + store.dispatch(setAgeGroup(group)) + //if ("**custom**" != this.props.query.ageGroup) { + this.fetch() + //} + }} + //update={ () => this.fetch() } + group={ this.props.query.ageGroup } + /> +
+
+
+
+ ) + } + + renderConditionsTabContents() { + return ( +
+
+
+ { + let condition = this.props.settings.server.conditions[key]; + return { + key, + label: condition.description, + data : condition + } + }) + } + onChange={ + selection => { + let conditions = {} + selection.forEach(tag => { + conditions[tag.key] = tag.data + }) + store.dispatch(setConditions(conditions)) + this.fetch() + } + } + label="condition code" + selected={ Object.keys(this.props.query.conditions) } + /> +
+
+
+ ) + } + + renderTagsTabContents() { + let selected = this.props.query.tags || this.props.settings.server.tags.filter( + tag => !!tag.selected + ).map(tag => !!tag.key); + return ( +
+
+
+ { + let tags = Object.keys(sel).map(k => sel[k].key) + store.dispatch(setTags(tags)) + this.fetch() + } + } + label="tag" + /> +
+
+
+ ) + } + + render() { + let _query = parseQueryString(this.props.location.search); + let _advanced = this.props.query.queryType == "advanced"; + let conditionsCount = Object.keys(this.props.query.conditions).length; + let demographicsCount = 0; + let tagsCount = Object.keys(this.props.query.tags).length; + + // Compute which the active tab should be + let tabs = ["demographics", "conditions"]; + if (!this.props.settings.hideTagSelector) { + tabs.push("tags"); + } + let _tab = _query._tab || ""; + if (tabs.indexOf(_tab) == -1) { + _tab = "demographics"; + } + + // Manually increment the value for the demographics badge depending on + // the state of the app + + if (this.props.query.gender) { + demographicsCount += 1; + } + + if (this.props.query.params.name) { + demographicsCount += 1; + } + + if (this.props.query.maxAge !== null || this.props.query.minAge !== null) { + demographicsCount += 1; + } + + return ( +
+ +
+
+ { this.renderAdvancedTabContents() } +
+
+ { this.renderDemographicsTabContents() } +
+
+ { this.renderConditionsTabContents() } +
+ { + this.props.settings.hideTagSelector ? + null : +
+ { this.renderTagsTabContents() } +
+ } + { + !_advanced && this.props.settings.submitStrategy == "manual" ? +
+ +
: + null + } +
+ { + _advanced ? + null : + { + store.dispatch(setSort(sort)) + store.dispatch(fetch()) + }} + /> + } +
+ ) + } +} diff --git a/src/components/Loader/Loader.less b/src/components/Loader/Loader.less new file mode 100644 index 0000000..c9f9272 --- /dev/null +++ b/src/components/Loader/Loader.less @@ -0,0 +1,24 @@ +.loader { + background : fade(#FFF, 50%); + text-align : center; + position : absolute; + z-index : 2; + top : 0; + right : 0; + bottom : 0; + left : 0; + border-radius: inherit; + + div { + position : absolute; + top : 50%; + left : 50%; + font-size : 18px; + transform : translate(-50%, -50%); + background : #FFF; + border-radius: 5px; + padding : 1ex 2em; + box-shadow : 0 0 1px 0px rgba(0, 0, 0, 0.5), + 0 0 15px 1px rgba(0, 0, 0, 0.15); + } +} \ No newline at end of file diff --git a/src/components/Loader/index.js b/src/components/Loader/index.js new file mode 100644 index 0000000..820283d --- /dev/null +++ b/src/components/Loader/index.js @@ -0,0 +1,15 @@ +import React from "react" +import "./Loader.less" + +export default class Loader extends React.Component +{ + render() { + return ( +
+
+ Loading... +
+
+ ) + } +} \ No newline at end of file diff --git a/src/components/PatientDetail/PatientDetail.less b/src/components/PatientDetail/PatientDetail.less new file mode 100644 index 0000000..9942b7f --- /dev/null +++ b/src/components/PatientDetail/PatientDetail.less @@ -0,0 +1,117 @@ +.patient-detail-page { + overflow : auto; + padding : 65px 0 50px; + background: linear-gradient(#FFF 60%, #EEE) scroll; + + > .container { + width: 100%; + } + + .navigator { + color: #FFF; + + a { + white-space: nowrap; + padding : 8px 15px; + margin : 5px 0; + color : #FFF; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + } + .fa { + margin: 0 5px; + } + + } + + .embed-responsive { + padding-bottom: 100%; + } + + .page-header { + margin-bottom : 0; + padding-bottom: 1.5ex; + border-bottom : 1px solid rgba(0, 0, 0, 0.1); + } + + .patient { + background: transparent; + padding : 0; + border : 0; + box-shadow: none; + position : relative; + + .text-left { + text-shadow: 0 0 1px rgba(0, 0, 0, 0.1); + } + } + + .patient-row { + background : rgba(0, 0, 0, 0.05); + border-radius: 3px; + margin-bottom: 5px; + padding : 5px; + + &:after { + content: ""; + display: table; + clear : both; + } + + ~ .row > div { + padding: 5px; + } + + h3 { + margin : 0; + line-height: 1.45em; + } + + .label { + margin : 8px 0 0 12px; + font-size: 13px; + } + + .btn { + margin-right: -15px; + } + } + + .patient-image-wrap { + margin-right: 1ex; + } + + .patient-name { + font-size: 24px; + } + + td, th { + > span, > small { + white-space: nowrap; + } + } + + .patient-details { + white-space: normal; + word-wrap : break-word; + + .badge { + opacity: 0.75; + margin : 0 0 0 2px; + } + table { + a { + white-space: normal; + word-break : break-all; + word-wrap : break-word; + } + } + } + + .list-group-item { + padding-top: 7px; + padding-bottom: 7px; + } +} diff --git a/src/components/PatientDetail/index.js b/src/components/PatientDetail/index.js new file mode 100644 index 0000000..e75171e --- /dev/null +++ b/src/components/PatientDetail/index.js @@ -0,0 +1,447 @@ +import React from "react" +import PatientImage from "../PatientImage" +import Loader from "../Loader" +import { connect } from "react-redux" +import $ from "jquery" +import { Link } from "react-router-dom" +import DialogFooter from "../DialogFooter" +import { toggle } from "../../redux/selection" +import store from "../../redux" +import { queryBuilder } from "../../redux/query" +import Observations from "../Fhir/Observation" +import ImmunizationList from "../Fhir/ImmunizationList" +import ConditionList from "../Fhir/ConditionList" +import MedicationRequest from "../Fhir/MedicationRequest" +// import Encounter from "../Fhir/Encounter" +import CarePlan from "../Fhir/CarePlan" +import ResourceList from "../Fhir/ResourceList" +import { + getErrorMessage, + getPatientName, + getPatientPhone, + getPatientEmail, + getPatientHomeAddress, + getPatientAge, + getPath, + intVal, + getBundleURL, + parseQueryString, + getPatientMRN, + getAllPages +} from "../../lib" +import "./PatientDetail.less" + +/** + * Renders the detail page. + */ +export class PatientDetail extends React.Component +{ + static propTypes = { + match : React.PropTypes.object, + settings : React.PropTypes.object, + selection: React.PropTypes.object, + query : React.PropTypes.object + }; + + constructor(...args) + { + super(...args) + this.query = queryBuilder.clone(); + this.state = { + loading : false, + error : null, + conditions : [], + patient : {}, + index : 0, + hasNext : false, + groups : {}, + selectedSubCat: "", + bundle : $.isEmptyObject(this.props.query.bundle) ? + null : + { ...this.props.query.bundle } + }; + } + + componentDidMount() + { + this.fetch(this.props.match.params.index); + } + + componentWillReceiveProps(newProps) + { + if (newProps.match.params.index !== this.props.match.params.index) { + this.fetch(newProps.match.params.index); + } + } + + fetch(index) + { + index = intVal(index, -1); + if (index < 0) { + return this.setState({ error: new Error("Invalid patient index") }); + } + + this.setState({ loading: true, index }, () => { + this.fetchPatient(this.props.settings.server, index) + .then( + state => { + // console.log(state); + this.setState({ + ...state, + error: null, + loading: false + }) + } + ) + .catch(error => { + this.setState({ + loading : false, + error + }) + }) + }) + } + + /** + * This page receives an "index" url parameter. It will repeat the same + * query from the search page but with limit=1 and offset=index so that the + * search returns only one page. The reason for this is that we also have + * these "Prev Patient" and "Next Patient" buttons that allow the user to + * walk through the result one patient at a time. + * @param {Object} server.url & server.type ... + * @param {Number} index + */ + fetchPatient(server, index) + { + // ===================================================================== + // Warning! The following are some ugly workarounds for the ugly API + // that does not support normal pagination! + // ===================================================================== + + return Promise.resolve({ ...this.state }) + + // Run the main query to fetch the first page + .then(state => { + this.query.cacheId = null; + this.query.offset = null; + return this.query.fetch(server).then( + bundle => { + state.bundle = bundle; + return state; + } + ); + }) + + // Try to find the patient by index + .then(state => { + index = intVal(index, -1); + if (index < 0) { + return Promise.reject("Invalid patient index"); + } + + state.patient = getPath(state, `bundle.entry.${index}.resource`); + state.nextURL = getBundleURL(state.bundle, "next"); + state.hasNext = state.patient ? index < state.bundle.entry.length - 1 : false; + return state; + }) + + // if no patient - jump to it's index + .then(state => { + if (!state.patient) { + let params = parseQueryString(state.nextURL); + this.query.setOffset(params._getpages, index); + return this.query.fetch(server).then( + bundle => { + state.bundle = bundle; + state.patient = getPath(state, `bundle.entry.0.resource`); + state.hasNext = state.patient ? + this.query.limit && this.query.limit > 1 ? + state.bundle.entry.length > 1 : + getBundleURL(state.bundle, "next") : + false; + return state; + } + ) + } + return state; + }) + + // Find $everything + .then(state => { + return getAllPages({ url: `${server.url}/Patient/${state.patient.id}/$everything` }) + .then(data => { + let groups = {}; + data.forEach(entry => { + let resourceType = getPath(entry, "resource.resourceType") || "Other"; + let type = resourceType; + + if (type == "Observation") { + type += " - " + ( + getPath(entry, "resource.category.0.text") || + getPath(entry, "resource.category.0.coding.0.code") || + "Other" + ).toLowerCase(); + } + + if (!Array.isArray(groups[type])) { + groups[type] = [] + } + groups[type].push(entry) + }) + state.groups = groups + return state + }) + }) + + // Feed the results to the app + .then( + state => Promise.resolve(state), + error => Promise.reject(new Error(getErrorMessage(error))) + ) + } + + // Rendering methods ------------------------------------------------------- + + renderPatient() + { + if (this.state.error) { + return this.state.error + "" + } + + let selected = this.props.selection[this.state.patient.id]; + return ( +
+
+ { this.state.loading ? : null } +
+
+ +
+
+
+
+
+

+ { getPatientName(this.state.patient) } +

+ { + selected ? + Selected : + null + } +
+
+ +
+
+
+
Gender:
+
+ { this.state.patient.gender || "Unknown" } +
+
DOB:
+
+ { this.state.patient.birthDate || "Unknown" } +
+
+
+
Age:
+
+ { getPatientAge(this.state.patient) || "Unknown" } +
+
Email
+
+ { getPatientEmail(this.state.patient) || "Unknown" } +
+
+
+
Phone:
+
+ { getPatientPhone(this.state.patient) || "Unknown" } +
+
Address:
+
+ { getPatientHomeAddress(this.state.patient || "Unknown") } +
+
+
+
ID:
+
+ { this.state.patient.id } +
+
MRN:
+
+ { getPatientMRN(this.state.patient) || "Unknown" } +
+
+ { + this.state.patient.deceasedBoolean || this.state.patient.deceasedDateTime ? + ( +
+
+ Deceased: +
+
+ { + this.state.patient.deceasedDateTime ? + { this.state.patient.deceasedDateTime } : + Yes + } +
+
+ ) : + null + } +
+
+
+ ); + } + + renderResources(type) { + let items = this.state.groups[type] || []; + if (!items.length) { + return ( +

+ No items of type "{type}" found +

+ ) + } + + switch (type) { + case "Observation": + return ; + case "Immunization": + return ; + case "Condition": + return + case "MedicationRequest": + return ; + // case "Encounter": + // return ; + case "CarePlan": + return ; + default: + return ; + } + } + + render() + { + let groups = Object.keys(this.state.groups).sort() + let selectedSubCat = this.state.selectedSubCat + if (!selectedSubCat || !this.state.groups[selectedSubCat]) { + selectedSubCat = Object.keys(this.state.groups)[0] || "" + } + + return ( +
+ +
+ + { this.renderPatient() } + + { + groups.length ? +
+
+

Patient Details

+
+
+ +
+ { this.renderResources(selectedSubCat) } +
+
: + this.state.loading ? + null: +
+
+ No additional details for this patient +
+
+ } +
+ { + window.opener || (window.parent && window.parent !== window) ? + : + null + } +
+ ) + } +} + +export default connect(state => state)(PatientDetail) diff --git a/src/components/PatientImage/PatientImage.less b/src/components/PatientImage/PatientImage.less new file mode 100644 index 0000000..677c5ec --- /dev/null +++ b/src/components/PatientImage/PatientImage.less @@ -0,0 +1,15 @@ +.patient-image-wrap { + height : 80px; + width : 80px; + background : #F6F6F6; + // background : rgba(222, 222, 222, 0.2); + // background : radial-gradient(#FFF 60%, #DDD 100%) center 90% no-repeat; + border-radius : 3px; + text-align : center; + position : relative; + box-shadow : 0 0 0.5px 0 rgba(0, 0, 0, 0.5) inset, 0 0 1px 1px #FFF; + background-size: cover; + background-position: center; + flex-shrink : 0; + flex-grow : 0; +} \ No newline at end of file diff --git a/src/components/PatientImage/index.js b/src/components/PatientImage/index.js new file mode 100644 index 0000000..99bf7fc --- /dev/null +++ b/src/components/PatientImage/index.js @@ -0,0 +1,75 @@ +/* global jdenticon */ +import React from "react" +import { + getPatientImageUri, + getPatientName +} from "../../lib" + +import "./PatientImage.less" + +function hash(input) { + let out = String(input).toLowerCase().trim().split("") + .reduce((sum, c) => sum + c.charCodeAt(0).toString(16), ""); + while (out.length < 11) { + out += out.length ? out : "000000000000" + } + return out; +} + +export default class PatientImage extends React.Component +{ + static propTypes = { + patient : React.PropTypes.object.isRequired, + className: React.PropTypes.string, + style : React.PropTypes.object, + base : React.PropTypes.string + }; + + static defaultProps = { + base: "" + }; + + componentDidMount() { + this.renderCanvas() + } + + componentDidUpdate() { + this.renderCanvas() + } + + renderCanvas() { + let url = getPatientImageUri(this.props.patient, this.props.base) + if (!url) { + let hex = hash(getPatientName(this.props.patient)); + jdenticon.update("#img-" + this.props.patient.id, hex, 0.1) + } + } + + render() { + let { className, style, patient, base, ...rest } = this.props; + let url = getPatientImageUri(patient, this.props.base); + className = "patient-image-wrap " + (className || "") + + style = { ...(style || {}) } + + if (url) { + style.backgroundImage = `url('${url}')` + style.backgroundColor = 'rgba(255, 255, 255, 0.6)' + } + + return ( +
+ { + url ? + null : + + } +
+ ) + } +} \ No newline at end of file diff --git a/src/components/PatientList/PatientList.less b/src/components/PatientList/PatientList.less new file mode 100644 index 0000000..a15e048 --- /dev/null +++ b/src/components/PatientList/PatientList.less @@ -0,0 +1,121 @@ +@import "../../less/variables.less"; + +.patient-search-results { + display : flex; + flex-grow : 1; + flex-direction: column; + background : #FFF; + overflow : auto; + + .male { + color: #369; + } + + .female { + color: #936; + } + + .patient { + position : relative; + padding : 1ex; + cursor : pointer; + flex-flow : 0; + flex-shrink : 0; + align-items : center; + display : flex; + transition : background 0.1s; + color : @color-fg-body; + text-decoration: none; + box-shadow : 0 1px 0 rgba(255, 255, 255, 0.85) inset, + 0 -1px 0 rgba(0, 0, 0, 0.15) inset; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + .patient-select-zone { + display: inline-block; + vertical-align: middle; + height: 54px; + line-height: 54px; + width: 30px; + border-radius: 3px; + text-align: center; + color: @color-bg-selection; + + &:hover { + background: fade(@color-bg-selection, 10%); + } + } + + .fa-angle-right { + opacity : 0.5; + font-size: 20px; + display : flex; + flex-basis: 1em; + } + + .patient-image-wrap { + height: 54px; + width : 54px; + margin: 0 1ex; + } + + .patient-info { + flex-grow: 1; + + > b { + font-size: 16px; + line-height: 1; + display: block; + margin-bottom: 2px; + } + + small { + opacity: 0.7; + display: block; + } + + footer { + opacity: 0.7; + display: flex; + flex-direction: row; + margin-top: 5px; + + > span { + display : flex; + flex-basis: 14em; + flex-grow: 1; + + + span { + margin-left: 2em; + } + } + } + } + + &.selected { + background: @color-bg-selection; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05) inset, + 0 -1px 0 rgba(0, 0, 0, 0.15) inset; + text-shadow: 0 0 1px #000; + + &, * { + color: @color-fg-selection; + } + + .patient-image-wrap { + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3), + 0 0 0 0.5px rgba(255, 255, 255, 0.5) inset; + } + + .patient-select-zone:hover { + background: rgba(255, 255, 255, 0.07) + } + + &:hover { + background: darken(@color-bg-selection, 3%); + } + } + } +} \ No newline at end of file diff --git a/src/components/PatientList/index.js b/src/components/PatientList/index.js new file mode 100644 index 0000000..1dd09e6 --- /dev/null +++ b/src/components/PatientList/index.js @@ -0,0 +1,109 @@ +import React from "react" +import PatientListItem from "../PatientListItem" +import { toggle, setAll } from "../../redux/selection" +import { showSelectedOnly } from "../../redux/settings" +import { setParam, fetch } from "../../redux/query" +import store from "../../redux" +import { connect } from "react-redux" +import Footer from "../Footer" +import Header from "../Header" +import ErrorMessage from "../ErrorMessage" +import Alert from "../Alert" +import "./PatientList.less" + + + +const TYPES = React.PropTypes + +export class PatientList extends React.Component +{ + static propTypes = { + query : TYPES.object, + selection: TYPES.object, + location : TYPES.object, + settings : TYPES.object, + urlParams: TYPES.object, + dispatch : TYPES.func + }; + + render() { + return ( +
+
+
+ { this.renderContents() } +
+
+
+ ) + } + + renderContents() { + if (this.props.query.error) { + return + } + + if (!this.props.query.bundle || this.props.query.loading) { + return ( +
+ Loading. Please wait... +
+ ) + } + + if (!this.props.query.bundle.entry || !this.props.query.bundle.entry.length) { + return ( + + No patients found to match this search criteria + + ) + } + + return this.renderPatientItems() + } + + renderPatientItems() { + let offset = this.props.query.offset || 0 + let items = this.props.query.bundle.entry || []; + if (this.props.settings.renderSelectedOnly) { + items = items.filter(o => !!this.props.selection[o.resource.id]) + } + return items.map((o, i) => ( + { + if (this.props.settings.renderSelectedOnly && + Object.keys(this.props.selection).filter(k => !!this.props.selection[k]).length === 1) + { + store.dispatch(setAll({})) + store.dispatch(showSelectedOnly(false)) + store.dispatch(setParam({ name: "_id", value: undefined })) + store.dispatch(fetch()) + } + else { + store.dispatch(toggle(patient)) + } + }} + query={ this.props.query } + settings={ this.props.settings } + /> + )) + } +} + +export default connect(state => state)(PatientList); diff --git a/src/components/PatientListItem/index.js b/src/components/PatientListItem/index.js new file mode 100644 index 0000000..b31ebc4 --- /dev/null +++ b/src/components/PatientListItem/index.js @@ -0,0 +1,93 @@ +import React from "react" +import { Link } from "react-router-dom" +import PatientImage from "../PatientImage" +import { + getPatientName, + getPatientAge, + renderSearchHighlight, + getPatientMRN +} from "../../lib" + +const TYPES = React.PropTypes + +export default class PatientListItem extends React.Component +{ + /** + * This component expects a Fhir Bundle + */ + static propTypes = { + patient : TYPES.object, + query : TYPES.object, + settings : TYPES.object, + selected : TYPES.bool, + onSelectionChange: TYPES.func, + index : TYPES.number + }; + + static defaultProps = { + onSelectionChange: () => 1 + }; + + render() { + let age = getPatientAge(this.props.patient) + let name = getPatientName(this.props) + if (this.props.query.params.name) { + name = renderSearchHighlight(name, this.props.query.params.name) + } + + return ( + +
{ + e.stopPropagation() + e.preventDefault() + this.props.onSelectionChange(this.props.patient) + }} + > + +
+ +
+ { name } { + this.props.patient.deceasedBoolean || this.props.patient.deceasedDateTime ? + Deceased : + null + } + {age} old { this.renderGender() } +
+ + DOB: { this.props.patient.birthDate || "Unknown" } + { + this.props.patient.deceasedDateTime ? + ' / DOD: ' + this.props.patient.deceasedDateTime : + null + } + + ID: { this.props.patient.id || "Unknown" } + MRN: { getPatientMRN(this.props.patient) || "Unknown" } +
+
+ + + ) + } + + renderGender() { + let gender = this.props.patient.gender + if (!gender) { + return null + } + + return ( + { gender } + ); + } +} \ No newline at end of file diff --git a/src/components/SortWidget/SortWidget.less b/src/components/SortWidget/SortWidget.less new file mode 100644 index 0000000..3daecb1 --- /dev/null +++ b/src/components/SortWidget/SortWidget.less @@ -0,0 +1,30 @@ +.sort-widget { + padding : 3px 15px; + float : left; + width : 100%; + + > span { + padding : 3px 1em 0 0; + font-weight: bold; + } + + .nav.nav-pills li { + margin-right: 1em; + + a { + padding: 4px 8px; + + span { + opacity: 0.6; + } + } + + &.active a, &:hover a, &:active a, a:focus { + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset; + } + + &.active a { + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.3); + } + } +} \ No newline at end of file diff --git a/src/components/SortWidget/index.js b/src/components/SortWidget/index.js new file mode 100644 index 0000000..b336c33 --- /dev/null +++ b/src/components/SortWidget/index.js @@ -0,0 +1,115 @@ +import React from "react" +import "./SortWidget.less" + + +export default class SortWidget extends React.Component +{ + static propTypes = { + /** + * Fhir sort string like "status,-date,category" + */ + sort: React.PropTypes.string, + + options: React.PropTypes.arrayOf(React.PropTypes.shape({ + name : React.PropTypes.string, + value: React.PropTypes.string + })), + + onChange: React.PropTypes.func + }; + + static defaultProps = { + sort: "name,-birthdate", + options: [ + { + name : "Patient ID", + value: "_id" + }, + { + name : "Name", + value: "given" + }, + { + name : "Gender", + value: "gender" + }, + { + name : "DOB", + value: "birthdate" + } + ] + }; + + change(name, value) { + // console.log(name, value) + if (typeof this.props.onChange == "function") { + let sort = this.parseSort(this.props.sort) + if (!value) { + if (sort.hasOwnProperty(name)) { + delete sort[name]; + } + } + else { + sort[name] = value + } + // console.log(sort, this.compileSort(sort)) + this.props.onChange(this.compileSort(sort)) + } + } + + parseSort(input) { + let sort = {}; + String(input || "").split(",").filter(Boolean).forEach(s => { + let name = s.replace(/^\-/, ""); + sort[name] = s.indexOf("-") === 0 ? "desc" : "asc"; + }) + return sort + } + + compileSort(sort) { + return Object.keys(sort).map(k => sort[k] == "desc" ? `-${k}` : k).join(",") + } + + render() { + let sort = this.parseSort(this.props.sort) + + return ( + + ) + } +} \ No newline at end of file diff --git a/src/components/TagSelector/TagSelector.less b/src/components/TagSelector/TagSelector.less new file mode 100644 index 0000000..3dae1cd --- /dev/null +++ b/src/components/TagSelector/TagSelector.less @@ -0,0 +1,175 @@ +@import "../../less/variables.less"; + +.tag-selector { + position : relative; + display : block; + width : 100%; + color : #000; + min-width: 200px; + font-size: small; + + .tags { + margin-bottom: 4px; + } + + input { + padding-right: 24px; + } + + .tag { + margin : 1px 6px 1px 0px; + padding : 3px 26px 3px 6px; + background : #DC9; + box-shadow : 0 0 1px 0 rgba(0, 0, 0, 0.5) inset; + border-radius: 3px; + color : #520; + text-shadow : 0 1px 1px rgba(255, 255, 255, 0.3); + display : inline-block; + position : relative; + font-weight : 500; + line-height : 1.2; + font-size : 12px; + cursor : default; + + &:last-of-type { + margin-right: 0; + } + + .tag-remove { + position : absolute; + top : 4px; + right : 5px; + color : rgba(0, 0, 0, 0.4); + text-shadow: none; + opacity : 0.6; + } + + &:hover { + background-color: darken(#DC9, 5%); + .tag-remove { + opacity: 0.5; + + &:hover { + opacity: 1; + color: #900; + } + } + } + + &.custom { + @c: mix(@color-bg-selection, #FFF, 40%); + background : @c; + color: darken(@color-bg-selection, 20%); + &:hover { + background-color: darken(@c, 5%); + } + } + } + + .menu { + padding : 4px; + top : 100%; + width : 100%; + text-align: left; + max-height: 50vh; + overflow : auto; + z-index : 10000000000; + } + + .menu-item { + padding : 5px 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: default; + + small { + line-height: 1.4; + font-weight: 400; + } + + .code { + width: 110px; + display: inline-block; + text-align: right; + padding-right: 1ex; + overflow: hidden; + text-overflow: ellipsis; + float: left; + margin: 1px 0 -1px; + } + + &:hover { + background: rgba(0, 0, 0, 0.1); + } + + &.selected { + color: @color-fg-selection; + background: @color-bg-selection; + text-shadow: 0 0 1px contrast(@color-fg-selection); + + small { + color: fade(@color-fg-selection, 60%); + text-shadow: none; + } + + .tag.custom { + box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.5); + } + } + + &.custom { + font-weight: bold; + cursor: pointer; + padding: 3px 10px; + + + .menu-item { + position : relative; + overflow : visible; + margin-top: 9px; + + + &:before { + content : ""; + display : block; + height : 1px; + background: #CCC; + position : absolute; + top : -5px; + left : 0; + right : 0; + } + } + + .tag { + margin : 0 1px; + padding : 3px 6px; + line-height: 1; + } + } + } + + &.open { + + outline: none; + + .input { + // border-radius: 5px 5px 0 0; + // box-shadow: 0 5px 5px -2px rgba(0, 0, 0, 0.3); + // border-color: #666; + } + .menu { + // display: block; + // border-color: #666; + // border-top: 0px none; + // box-shadow: 0 5px 5px -2px rgba(0, 0, 0, 0.3); + } + } + + .fa.fa-caret-down { + position: absolute; + right: 10px; + bottom: 10px; + pointer-events: none; + } +} \ No newline at end of file diff --git a/src/components/TagSelector/__tests__/TagSelector.js b/src/components/TagSelector/__tests__/TagSelector.js new file mode 100644 index 0000000..8052d53 --- /dev/null +++ b/src/components/TagSelector/__tests__/TagSelector.js @@ -0,0 +1,334 @@ +/* global describe, it, chai, $ */ +import TagSelector from ".." +import ReactDOM from "react-dom" +import TestUtils from "react-addons-test-utils" +import React from "react" + +const expect = chai.expect; + +class Wrap extends React.Component { + constructor(...args) { + super(...args) + this.state = { ...this.props } + } + + update(newProps) { + this.setState(newProps) + } + + render() { + return + } +} + +describe ("TagSelector", () => { + + describe ("constructor", () => { + it ("creates the proper initial state", () => { + let widget = TestUtils.renderIntoDocument(); + expect(widget.state).to.deep.equal({ + selected: [], + open: false, + selectedIndex: -1, + search: "" + }) + }); + }); + + describe ("componentWillReceiveProps", () => { + it ("updates the search", () => { + let wrap = TestUtils.renderIntoDocument(); + expect(wrap.refs.component.state.search).to.equal("") + wrap.update({ search: "x" }) + expect(wrap.refs.component.state.search).to.equal("x") + }); + }); + + describe ("componentDidUpdate", () => { + it ("TODO: test the menu scroll position if possible"); + }); + + describe ("onFocus", () => { + it ("opens the menu", () => { + let widget = TestUtils.renderIntoDocument(); + let wrap = ReactDOM.findDOMNode(widget); + let input = $("input", wrap); + expect(widget.state.open).to.equal(false); + TestUtils.Simulate.focus(input[0]); + expect(widget.state.open).to.equal(true); + }); + }); + + describe ("onBlur", () => { + it ("closes the menu", () => { + let widget = TestUtils.renderIntoDocument(); + let wrap = ReactDOM.findDOMNode(widget); + let input = $("input", wrap); + TestUtils.Simulate.focus(input[0]); + expect(widget.state.open).to.equal(true); + TestUtils.Simulate.blur(input[0]); + expect(widget.state.open).to.equal(false); + }); + }); + + describe ("onInput", () => { + it ("opens the menu", () => { + let widget = TestUtils.renderIntoDocument(); + let wrap = ReactDOM.findDOMNode(widget); + let input = $("input", wrap); + expect(widget.state.open).to.equal(false); + TestUtils.Simulate.input(input[0], { keyCode: 69 }); + expect(widget.state.open).to.equal(true); + }); + + it ("updates the search", () => { + let widget = TestUtils.renderIntoDocument(); + let wrap = ReactDOM.findDOMNode(widget); + let input = $("input", wrap).val("e"); + TestUtils.Simulate.input(input[0], { keyCode: 69 }); + expect(widget.state.search).to.equal("e") + expect(input[0].value).to.equal("e") + }); + + it ("preselects the first option if no selection exists", () => { + let widget = TestUtils.renderIntoDocument( + + ) + let wrap = ReactDOM.findDOMNode(widget); + let input = $("input", wrap).val("e"); + expect(widget.state.selectedIndex).to.equal(-1) + TestUtils.Simulate.input(input[0], { keyCode: 69 }); + expect(widget.state.selectedIndex).to.equal(0) + }); + }); + + describe ("onKeyDown", () => { + it ("Escape", () => { + let widget = TestUtils.renderIntoDocument(); + let wrap = ReactDOM.findDOMNode(widget); + let input = $("input", wrap); + expect(widget.state.open).to.equal(false); + TestUtils.Simulate.focus(input[0]); + expect(widget.state.open).to.equal(true); + TestUtils.Simulate.keyDown(input[0], { keyCode: 27 }); + expect(widget.state.open).to.equal(false); + }); + + it ("Enter", () => { + let widget = TestUtils.renderIntoDocument( + + ) + let wrap = ReactDOM.findDOMNode(widget); + let input = $("input", wrap).val("option-b"); + expect(widget.state.selectedIndex).to.equal(-1) + TestUtils.Simulate.input(input[0]); + expect(widget.state.selectedIndex).to.equal(0) + TestUtils.Simulate.keyDown(input[0], { keyCode: 13 }); + expect(widget.state).to.deep.equal({ + selectedIndex: -1, + open : false, + search : "", + selected: [ + { + custom: true, + key: "option-b", + label: "option-b", + data: { + description: "option-b", + codes: { + "": [ "option-b" ] + } + } + } + ] + }); + }); + + it ("Arrow Down", () => { + let widget = TestUtils.renderIntoDocument( + + ) + let wrap = ReactDOM.findDOMNode(widget); + let input = $("input", wrap); + expect(widget.state.selectedIndex).to.equal(-1) + TestUtils.Simulate.keyDown(input[0], { keyCode: 40 }); + expect(widget.state.selectedIndex).to.equal(0) + TestUtils.Simulate.keyDown(input[0], { keyCode: 40 }); + expect(widget.state.selectedIndex).to.equal(1) + }); + + it ("Arrow Up", () => { + let widget = TestUtils.renderIntoDocument( + + ) + let wrap = ReactDOM.findDOMNode(widget); + let input = $("input", wrap); + TestUtils.Simulate.keyDown(input[0], { keyCode: 40 }); + TestUtils.Simulate.keyDown(input[0], { keyCode: 40 }); + TestUtils.Simulate.keyDown(input[0], { keyCode: 40 }); + expect(widget.state.selectedIndex).to.equal(2) + TestUtils.Simulate.keyDown(input[0], { keyCode: 38 }); + expect(widget.state.selectedIndex).to.equal(1) + TestUtils.Simulate.keyDown(input[0], { keyCode: 38 }); + expect(widget.state.selectedIndex).to.equal(0) + TestUtils.Simulate.keyDown(input[0], { keyCode: 38 }); + expect(widget.state.selectedIndex).to.equal(-1) + TestUtils.Simulate.keyDown(input[0], { keyCode: 38 }); + expect(widget.state.selectedIndex).to.equal(-1) + }); + }); + + it ("addTag", () => { + let widget = TestUtils.renderIntoDocument(); + expect(widget.state.selected).to.deep.equal([]); + widget.addTag({ key: "b" }) + expect(widget.state.selected).to.deep.equal([ + { key: "b" } + ]); + widget.addTag({ key: "a" }) + expect(widget.state.selected).to.deep.equal([ + { key: "b" }, + { key: "a" } + ]); + }); + + it ("removeTag", () => { + let widget = TestUtils.renderIntoDocument(); + expect(widget.state.selected).to.deep.equal([]); + widget.addTag({ key: "b" }) + widget.addTag({ key: "a" }) + widget.addTag({ key: "c" }) + widget.removeTag("a") + expect(widget.state.selected).to.deep.equal([ + { key: "b" }, + { key: "c" } + ]); + }); + + describe ("filterTags", () => { + + it ("filters and sorts the list", () => { + let tagA = { key: "a", label: "option-a", data: { codes: { "SNOMED-CT": ["code-a"] }}}; + let tagB = { key: "b", label: "option-b", data: { codes: { "SNOMED-CT": ["code-b"] }}} + let tagC = { key: "c", label: "option-c", data: { codes: { "SNOMED-CT": ["code-c"] }}}; + let widget = TestUtils.renderIntoDocument(); + expect(widget.filterTags()).to.deep.equal([ tagA, tagB, tagC ]); + expect(widget.filterTags("code-b")).to.deep.equal([ tagB ]); + expect(widget.filterTags("option-c")).to.deep.equal([ tagC ]); + }); + + it ("prepends the custom search option", () => { + let tagA = { key: "a", label: "option-a", data: { codes: { "SNOMED-CT": ["code-a"] }}}; + let widget = TestUtils.renderIntoDocument(); + expect(widget.filterTags()).to.deep.equal([ + { + key : "test", + label : "test", + custom: true, + data : { + description: "test", + codes: { + "": ["test"] + } + } + }, + tagA + ]); + }) + }); + + it ("renderTagCode", () => { + let widget = TestUtils.renderIntoDocument(); + expect(widget.renderTagCode()).to.equal(null) + expect(widget.renderTagCode({})).to.equal(null) + expect(widget.renderTagCode({ data: 5 })).to.equal(null) + let code = TestUtils.renderIntoDocument(widget.renderTagCode({ + data: { + codes: { + "sys": [1234] + } + } + })) + let small = ReactDOM.findDOMNode(code); + expect(small.textContent).to.equal("1234 (sys):") + }); + + it ("renderTag(tag, index)"); + + describe ("render", () => { + it ("builds proper html", () => { + let widget = TestUtils.renderIntoDocument( + + ) + let wrap = ReactDOM.findDOMNode(widget); + let input = $("input", wrap) + let menu = $(".menu", wrap); + let options = $(".menu-item", menu); + + expect(input.length).to.equal(1) + expect(menu.length).to.equal(1) + expect(options.length).to.equal(3 + 1) + expect(input.val()).to.equal("option") + }); + }); + + /*it ("builds proper html", () => { + let widget = TestUtils.renderIntoDocument( + + ) + let wrap = ReactDOM.findDOMNode(widget); + let input = $("input", wrap) + let menu = $(".menu", wrap); + let options = $(".menu-item", menu); + + TestUtils.Simulate.keyDown(input[0], { keyCode: 40 }); + + let sel = $(".menu-item.selected", menu) + + expect(input.length).to.equal(1) + expect(menu.length).to.equal(1) + expect(options.length).to.equal(3) + expect(sel.length).to.equal(1) + expect(sel.index()).to.equal(0) + expect(sel.text()).to.equal("code-a (SNOMED)a") + expect(input.val()).to.equal("") + + TestUtils.Simulate.keyDown(input[0], { keyCode: 40 }); + sel = $(".menu-item.selected", menu) + expect(sel.length).to.equal(1) + expect(sel.index()).to.equal(1) + expect(sel.text()).to.equal("code-b (SNOMED)b") + + TestUtils.Simulate.keyDown(input[0], { keyCode: 38 }); + sel = $(".menu-item.selected", menu) + expect(sel.length).to.equal(1) + expect(sel.index()).to.equal(0) + expect(sel.text()).to.equal("code-a (SNOMED)a") + })*/ + // it ("calls onChange for min.value") + // it ("calls onChange for min.units") +}) diff --git a/src/components/TagSelector/index.js b/src/components/TagSelector/index.js new file mode 100644 index 0000000..757f54f --- /dev/null +++ b/src/components/TagSelector/index.js @@ -0,0 +1,393 @@ +import React from "react" +import $ from "jquery" +import { renderSearchHighlight } from "../../lib" +import "./TagSelector.less" + +export default class TagSelector extends React.Component +{ + static propTypes = { + tags : React.PropTypes.arrayOf(React.PropTypes.object), + selected: React.PropTypes.arrayOf(React.PropTypes.string), + onChange: React.PropTypes.func, + search : React.PropTypes.string, + label : React.PropTypes.string + }; + + static defaultProps = { + onChange: () => 1, + tags : [], + selected: [], + search : "" + } + + constructor(...args) { + super(...args) + + this.state = { + // the selected items as a map of unique keys and tag objects + selected: [], + + // is the menu currently visible? + open: false, + + // the index of the currently selected option (-1 means no + // selection). + selectedIndex: -1, + + // The search therm (the value of the input field) + search : "" + }; + + this.stateFromProps(this.state, this.props) + + // bind the event handlers to preserve the "this" context + this.addTag = this.addTag.bind(this) + this.removeTag = this.removeTag.bind(this) + this.onFocus = this.onFocus.bind(this) + this.onBlur = this.onBlur.bind(this) + this.onKeyDown = this.onKeyDown.bind(this) + this.onInput = this.onInput.bind(this) + } + + stateFromProps(state, nextProps) { + let hasChanged = false + if (nextProps.search) { + state.search = nextProps.search; + hasChanged = true; + } + + if (nextProps.selected) { + state.selected = [ ...nextProps.selected.map( + t => this.props.tags.find(tag => tag.key === t) || { + key: t, + label: t, + custom: true, + data : { + description: t, + codes: { + "": [t] + } + } + } + ) ] + hasChanged = true + } + + return hasChanged; + } + + componentWillReceiveProps(nextProps) { + let nextState = {} + if (this.stateFromProps(nextState, nextProps)) { + this.setState(nextState); + } + } + + /** + * Updates the scroll position of the menu if it is opened and if some of + * it's options is selected so that the selected option is always in view. + */ + componentDidUpdate() { + let menu = this.refs.menu; + if (menu) { + let menuHeight = menu.clientHeight; + let menuScrollTop = menu.scrollTop; + let paddingTop = parseInt($(menu).css("paddingTop"), 10); + let paddingBottom = parseInt($(menu).css("paddingBottom"), 10); + let selected = $(".selected", this.refs.menu); + + if (selected.length) { + let selectedTop = selected[0].offsetTop; + let selectedHeight = selected[0].offsetHeight; + if (selectedTop < menuScrollTop) { + requestAnimationFrame(() => this.refs.menu.scrollTop = selectedTop - paddingTop) + } + else if (selectedTop + selectedHeight - menuScrollTop > menuHeight) { + requestAnimationFrame(() => this.refs.menu.scrollTop = selectedTop + selectedHeight + + paddingBottom - menuHeight); + } + } + } + } + + + + // Event handlers ---------------------------------------------------------- + + onFocus() { + this.setState({ open: true }) + } + + onBlur() { + this.setState({ open: false }) + } + + onInput(e) { + let listed = this.filterTags(e.target.value); + this.setState({ + search: e.target.value, + open: true, + selectedIndex: listed.length && this.state.selectedIndex == -1 ? + 0 : + this.state.selectedIndex + }) + } + + /** + * This will handle special keys like Enter, Escape and arrows for scrolling + */ + onKeyDown(e) { + let listed = this.filterTags(this.state.search); + switch (e.keyCode) { + + case 27: // Esc + this.setState({ open: false }) + break; + + case 13: { // Enter + // make sure we don't submit the form (if in any) + e.preventDefault(); + + if (listed.length < 2) { + this.setState({ open: false }) + } + + let index = this.state.selectedIndex; + if (index === listed.length - 1) { + this.setState({ selectedIndex: index - 1 }, () => { + this.addTag(listed[index]) + }) + } + else { + this.addTag(listed[index]) + } + + break; + } + + case 40: { // Down arrow + e.preventDefault(); + let maxIndex = listed.length - 1 + this.setState({ + open : true, + selectedIndex: Math.min(this.state.selectedIndex + 1, maxIndex) + }); + break; + } + + case 38: // Up arrow + e.preventDefault(); + if (this.state.selectedIndex > 0) { + this.setState({ + selectedIndex: this.state.selectedIndex - 1 + }); + } + else if (this.state.open) { + this.setState({ + open: false, + selectedIndex: -1 + }) + } + break; + } + } + + /** + * Happens when the user clicks on an option (or hits the enter key on + * selected option) + * @param {*} tag + * @returns {void} + */ + addTag(tag) { + + if (!tag || !tag.key) { + return; + } + + // this.shouldn't happen but just in case, check if somebody is trying + // to select something that is already selected + if (this.state.selected.find(t => t.key === tag.key)) { + return; + } + + this.setState({ + ...this.state, + selected : [ ...this.state.selected, tag ], + search : "", + open : false, + selectedIndex: -1 + }, () => this.props.onChange(this.state.selected)) + } + + /** + * Happens when the user clicks a close button on a selected tag + * @param {*} key + */ + removeTag(key) { + if (!key) return; + let idx = this.state.selected.findIndex(t => t.key === key); + if (idx > -1) { + this.state.selected.splice(idx, 1); + this.setState({}, () => this.props.onChange(this.state.selected)); + } + } + + // Private helper methods -------------------------------------------------- + + filterTags(search) { + let tags = this.props.tags.filter(t => { + + // skip the already selected options + if (this.state.selected.find(tag => t.key === tag.key)) { + return false + } + + // search for matches + if (search) { + + // first search by name + let index = t.label.toLowerCase().indexOf(search.toLowerCase()) + if (index > -1) { + return true + } + + // then search by code + if (t.data && t.data.codes && typeof t.data.codes == "object") { + for (let system in t.data.codes) { + return t.data.codes[system].some(c => ( + c.toLowerCase().indexOf(search.toLowerCase()) > -1 + )) + } + } + + return false + } + return true; + }).sort((a, b) => { + if (a.label > b.label) return 1 + if (a.label < b.label) return -1 + return 0 + }).map(t => ({ ...t })) + + if (this.state.search) { + tags.unshift({ + key : this.state.search, + label : this.state.search, + custom: true, + data : { + description: this.state.search, + codes: { + "": [this.state.search] + } + } + }) + } + + return tags + } + + // Rendering methods ------------------------------------------------------- + + renderTagCode(tag) { + if (!tag || !tag.data || !tag.data.codes) { + return null; + } + + let code = "", codes = tag.data.codes; + if (codes["SNOMED-CT"]) { + code = codes["SNOMED-CT"].join("|") + } + else { + for (let system in codes) { + code = `${codes[system].join(",")} (${system})` + break; + } + } + + if (this.state.search) { + code = renderSearchHighlight(code, this.state.search) + } + + return code ? + {code}: : + null; + } + + renderTag(tag, index) { + return ( +
{ e.preventDefault(); this.addTag(tag) }} + title={tag.label} + > + { + tag.custom ? + + Search for + { this.state.search } + { this.props.label ? " " + this.props.label : ""} : + null + } + { tag.custom ? null : this.renderTagCode(tag) } + { tag.custom ? null : renderSearchHighlight(tag.label, this.state.search) } +
+ ) + } + + render() { + let menuItems = this.filterTags(this.state.search).map(this.renderTag, this) + let emptyMenu = menuItems.length === 0 + let tags = this.state.selected.map(tag => ( +
+ { tag.label } + { + e.preventDefault(); + e.stopPropagation(); + this.removeTag(tag.key) + } + } + /> +
+ )) + + let placeholder = "Type to search" + if (this.props.label) { + placeholder += ` for ${this.props.label}s` + } + placeholder += ' ...' + + return ( +
+ { tags.length ?
{tags}
: null } + 1 } + value={ this.state.search } + /> + + { + emptyMenu && !this.state.search ? + null : +
+ { menuItems } +
+ } +
+ ) + } +} diff --git a/src/config.default.js b/src/config.default.js new file mode 100644 index 0000000..36ec553 --- /dev/null +++ b/src/config.default.js @@ -0,0 +1,56 @@ +export default { + server: { + type: "STU-3", // "DSTU-2" or "STU-3" + + // HSPC + // url: "https://sb-fhir-dstu2.smarthealthit.org/api/smartdstu2/open", + url: "https://sb-fhir-stu3.smarthealthit.org/smartstu3/open", + + // HAPI + // url: "http://fhirtest.uhn.ca/baseDstu3", + // url: "http://fhirtest.uhn.ca/baseDstu2", + + // MiHIN + // url: "http://34.195.196.20:9074/smartstu3", + // url: "http://52.90.126.238:8080/fhir/baseDstu3", + + // Other + // url: "http://sqlonfhir-dstu2.azurewebsites.net/fhir", + // url: "https://stub-dstu2.smarthealthit.org/api/fhir", + + conditions: {}, + + tags: [] + }, + + hideTagSelector: false, + + patientsPerPage: 10, + + // AJAX requests timeout + timeout: 20000, + + // Only the selected patients are rendered. Should be false or the + // preselected patient IDs should be passed to the window. Otherwise + // It will result in rendering no patients at all. + renderSelectedOnly: false, + + // If enabled is true (then url and param MUST be set) then clicking on the + // patient-related resources in detail view will open their source in that + // external viewer. Otherwise they will just be opened in new browser tab. + fhirViewer: { + enabled: true, + url : "http://docs.smarthealthit.org/fhir-viewer/index.html", + param : "url" + }, + + // What to send when the OK dialog button is clicked. Possible values: + // "id-list" - comma-separated list of patient IDs (default) + // "id-array" - array of patient IDs + // "patients" - array of patient JSON objects + outputMode: "id-list", + + // "automatic" -> onChange plus defer in some cases + // "manual" -> render a submit button + submitStrategy: "manual" +} diff --git a/src/index.ejs b/src/index.ejs new file mode 100644 index 0000000..af5a171 --- /dev/null +++ b/src/index.ejs @@ -0,0 +1,20 @@ + + + + + SMART Patient Browser + + + + + + +
+
+ + + + + + + \ No newline at end of file diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..f0567aa --- /dev/null +++ b/src/index.js @@ -0,0 +1,34 @@ +import React from "react" +import ReactDOM from "react-dom" +import App from "./components/App" +import { Provider } from "react-redux" +import STORE from "./redux" +import PatientDetail from "./components/PatientDetail" +import PatientList from "./components/PatientList" +import { Router, Route, Switch } from "react-router" +import createHistory from "history/createHashHistory" +import jQuery from "jquery" + +window.$ = window.jQuery = jQuery + +const history = createHistory() + +ReactDOM.render( + + + + + + + + + + , + document.getElementById("main") +) + +$(function () { + $("body").tooltip({ + selector : ".patient-detail-page [title]" + }) +}) \ No newline at end of file diff --git a/src/less/variables.less b/src/less/variables.less new file mode 100644 index 0000000..0eac49b --- /dev/null +++ b/src/less/variables.less @@ -0,0 +1,11 @@ + +// Colors + +@color-bg-body : #F6F6F6; +@color-fg-body : #444; +@color-bg-window : #FFF; +@color-fg-window : #000; +@color-bg-selection: #369; +@color-fg-selection: #FFF; + +// Fonts \ No newline at end of file diff --git a/src/lib/PatientSearch.js b/src/lib/PatientSearch.js new file mode 100644 index 0000000..fa01723 --- /dev/null +++ b/src/lib/PatientSearch.js @@ -0,0 +1,837 @@ +import moment from "moment" +import { CODE_SYSTEMS } from "./constants" +import { parseQueryString, request } from "." +import { intVal, getPath } from "." + +/** + * This is just a helper class that is used as a query builder. It has some + * dedicated setter methods for various query parameters and knows how to + * compile those into a query string that can be passed to the Patient endpoint + * of a fhir api server. + */ +export default class PatientSearch +{ + /** + * The constructor just creates an empty instance. Use the setter methods + * to set query params and then call compile to build the query string. + */ + constructor(options = {}) { + + this.__cache__ = {}; + + this.__scheduled__ = {}; + + /** + * The list of conditions that should be included in the query. + * @type {Object} A map of unique keys and condition objects + * @private + */ + this.conditions = { ...(options.conditions || {}) }; + + /** + * The desired minimal age of the patients as an object like + * { value: 5, units: "years" } + * @type {Object} + * @private + */ + this.minAge = null; + + /** + * The desired maximal age of the patients as an object like + * { value: 5, units: "years" } + * @type {Object} + * @private + */ + this.maxAge = null; + + /** + * The patient gender to search for (male|female) + * @type {String} + * @private + */ + this.gender = options.gender || null; + + /** + * How many patients to fetch per page. Defaults to null meaning that + * this param will not be included in the query and we are leaving it + * for the server to decide. + * @type {Number} + * @private + */ + this.limit = options.limit || null; + + /** + * How many patients to skip. Defaults to null meaning that + * this param will not be included in the query and we are leaving it + * for the server to decide. + * @type {Number} + * @private + */ + this.offset = null; + + /** + * A collection of additional parameters + * @type {Object} + * @private + */ + this.params = {}; + + /** + * This is like a flag that toggle the instance to different modes + * (currently only advanced and default are supported) + * @type {String} "advanced" or "default" + * @private + */ + this.queryType = options.queryType || "default"; + + /** + * The query string to use if in advanced mode + * @type {String} + * @private + */ + this.queryString = options.queryString || ""; + + /** + * The sort parameters list + * @type {String} + * @private + */ + this.sort = options.sort || ""; + + /** + * All the tags that the patients should be filtered by + * @type {Array} + * @private + */ + this.tags = String(options.tags || "").split(/\s*,\s*/).filter(Boolean); + } + + /** + * Schedule a prop change to be made before the next compile + * @param {Object} props + */ + schedule(props) { + this.__scheduled__ = { ...this.__scheduled__, ...props }; + } + + hasParam(name) { + return this.params.hasOwnProperty(name); + } + + /** + * Sets a param by name. Note that this is a lower level interface. It does + * not know anything about the parameter thus it will not handle UI + * dependencies (eg. it won't reset the offset for you) + * @param {Name} name The name of the parameter to set + * @param {*} value The value to set. Use undefined to remove a parameter + * @returns {PatientSearch} Returns the instance + */ + setParam(name, value) { + let has = this.hasParam(name) + if (value === undefined) { + if (has) { + delete this.params[name] + } + } + else { + this.params[name] = value + } + this.offset = null; + this.cacheId = null; + return this + } + + /** + * Sets the query type. In advanced mode a query string is provided and + * parsed and all the other parameters are ignored. In default mode the + * query string is ignored and only the other params are used. + * @param {String} type "advanced" or anything else for "default" + * @returns {PatientSearch} Returns the instance + */ + setQueryType(type) { + this.queryType = type == "advanced" ? "advanced" : "default" + return this + } + + /** + * Sets the query string to be used while in advanced mode. Note that this + * will not be used if not in advanced mode but the query string will still + * be persisted so that if the user switches the UI to advanced the last + * query can be displayed... + * @param {String} query The query string to use if in advanced mode + * @returns {PatientSearch} Returns the instance + */ + setQueryString(query) { + this.queryString = String(query || "") + return this + } + + /** + * Adds a condition to the list of patient conditions + * @param {String} key Unique string identifier for that condition + * @param {Object} condition The condition to add + * @returns {PatientSearch} Returns the instance + */ + addCondition(key, condition) { + this.conditions[key] = condition; + this.__cache__.patientIDs = null; + return this; + } + + /** + * Removes the condition identified by it's key. If that condition is not + * currently included it does nothing + * @param {*} key Unique string identifier for that condition + * @returns {PatientSearch} Returns the instance + */ + removeCondition(key) { + if (this.conditions.hasOwnProperty(key)) { + delete this.conditions[key]; + this.__cache__.patientIDs = null; + } + return this; + } + + /** + * Replaces the entire set of conditions at once + * @param {Object} conditions The new conditions to set + * @returns {PatientSearch} Returns the instance + */ + setConditions(conditions) { + this.conditions = { ...conditions }; + this.schedule({ + offset: null, + cacheId: null + }); + this.__cache__.patientIDs = null; + return this; + } + + addTag(tag) { + if (this.tags.findIndex(tag) == -1) { + this.tags.push(tag); + } + return this; + } + + removeTag(tag) { + let index = this.tags.findIndex(tag); + if (index > -1) { + this.tags.splice(index, 1); + } + return this; + } + + setTags(tags) { + this.tags = [ ...tags ] + this.schedule({ + offset: null, + cacheId: null + }); + return this; + } + + /** + * Sets the desired min age af the patients. This can also be set to null + * (or other falsy value) to exclude the minAge restrictions from the query. + * @param {Object} age The age + * @param {Number} age.value The age as number of @units + * @param {String} age.units The units for the value (years|months|days) + * @returns {PatientSearch} Returns the instance + */ + setMinAge(age) { + this.minAge = age; + this.schedule({ + offset: null, + cacheId: null + }); + return this; + } + + /** + * Sets the desired max age af the patients. This can also be set to null + * (or other falsy value) to exclude the maxAge restrictions from the query. + * @param {Object} age The age + * @param {Number} age.value The age as number of @units + * @param {String} age.units The units for the value (years|months|days) + * @returns {PatientSearch} Returns the instance + */ + setMaxAge(age) { + this.maxAge = age; + this.schedule({ + offset: null, + cacheId: null + }); + return this; + } + + /** + * Sets the min and max ages depending on the specified age group keyword + * @param {*} group Can be one of infant, child, adult, elderly. + * Anything else will clear the age constraints! + * @returns {PatientSearch} Returns the instance + */ + setAgeGroup(group) { + this.ageGroup = group; + this.schedule({ + offset: null, + cacheId: null + }); + + switch (group) { + + // infant - 0 to 12 months + case "infant": + this.setMinAge(null); + this.setMaxAge({ value: 1, units: "years" }); + break; + + // child - 1 to 18 years + case "child": + this.setMinAge({ value: 1 , units: "years" }); + this.setMaxAge({ value: 18, units: "years" }); + break; + + // adult - 18 to 65 years + case "adult": + this.setMinAge({ value: 18, units: "years" }); + this.setMaxAge({ value: 65, units: "years" }); + break; + + // Elderly - 65+ + case "elderly": + this.setMinAge({ value: 65, units: "years" }); + this.setMaxAge(null); + break; + + // Anything else clears the birthdate param + default: + this.setMinAge(null); + this.setMaxAge(null); + // this.ageGroup = null; + break; + } + return this; + } + + /** + * Sets the gender to search for. Can be "male" or "female". Any falsy value + * will clear the gender param + * @param {String} gender "male" or "female" + * @returns {PatientSearch} Returns the instance + */ + setGender(gender) { + if (gender !== this.gender) { + this.gender = gender; + this.schedule({ + offset : null, + cacheId: null + }); + } + return this; + } + + /** + * Sets how many patients will be fetched per page + * @param {number|string} limit The number of records to fetch + * @returns {PatientSearch} Returns the instance + */ + setLimit(limit) { + this.limit = intVal(limit) + if (this.limit < 1) { + this.limit = null; + } + return this; + } + + /** + * Sets how many patients will be skipped + * @param {string} cacheId The id generated by the server (_getpages) + * @param {number|string} offset The number of records to skip + * @returns {PatientSearch} Returns the instance + */ + setOffset(cacheId, offset) { + this.offset = intVal(offset) + this.cacheId = cacheId + if (this.offset < 1) { + this.offset = null; + this.cacheId = null; + } + return this; + } + + /** + * Sets the sorting to use + * @param {string} sort A fhir sort string like "status,-date,category" + * @returns {PatientSearch} Returns the instance + */ + setSort(sort) { + this.sort = sort + return this + } + + /** + * Returns another PatientSearch instance with the exact same state as this. + * @returns {PatientSearch} Returns the new copy + */ + clone() { + let inst = new PatientSearch(); + + inst.conditions = { ...this.conditions }; + inst.params = { ...this.params }; + inst.tags = [ ...this.tags ]; + + inst.setAgeGroup(this.ageGroup) + .setMinAge(this.minAge) + .setMaxAge(this.maxAge) + .setGender(this.gender) + .setLimit(this.limit) + .setOffset(this.cacheId, this.offset) + .setQueryType(this.queryType) + .setQueryString(this.queryString) + .setSort(this.sort); + + return inst; + } + + /** + * Clear all params. If you call compile after clear only the "_format=json" + * part should be returned + * @returns {PatientSearch} Returns the instance + */ + reset() { + this.conditions = {}; + this.minAge = null; + this.maxAge = null; + this.gender = null; + this.limit = null; + this.offset = null; + this.cacheId = null; + this.ageGroup = null; + this.params = {}; + this.queryString = ""; + this.queryType = "default" + this.sort = ""; + this.tags = []; + return this; + } + + /** + * Returns an object representing the current state of the instance. + * The object contains COPIES of the current param values. + * @returns {Object} + */ + getState() { + return { + conditions : this.conditions, + minAge : this.minAge, + maxAge : this.maxAge, + gender : this.gender, + limit : this.limit, + offset : this.offset, + cacheId : this.cacheId, + ageGroup : this.ageGroup, + params : { ...this.params }, + queryString : this.queryString, + queryType : this.queryType, + sort : this.sort, + tags : [ ...this.tags ] + }; + } + + /** + * Compiles and returns the query string that can be send to the Patient + * endpoint. + * @return {String} The compiled query string (without the "?" in front) + */ + compile(encode=true) { + let params = []; + + [ + // conditions + "minAge", + "maxAge", + "gender", + "limit", + "offset", + // "params", + "queryType", + "queryString", + "sort"//, + // tags + ].forEach(prop => { + if (this.__scheduled__.hasOwnProperty(prop)) { + this[prop] = this.__scheduled__[prop]; + delete this.__scheduled__[prop]; + } + }) + + // Tags ---------------------------------------------------------------- + if (this.tags.length) { + params.push({ name: "_tag", value: this.tags.join(",") }); + } + + // Advanced query ------------------------------------------------------ + if (this.queryType == "advanced") { + let str = this.queryString.trim() + if (str) { + let _query = parseQueryString(str); + for (let name in _query) { + params.push({ name, value: _query[name] }); + } + } + } + + // Default query ------------------------------------------------------- + else { + + // Custom params --------------------------------------------------- + Object.keys(this.params).forEach(k => params.push({ + name : k, + value: this.params[k] + })) + + // sort ------------------------------------------------------------ + if (this.sort) { + String(this.sort).split(",").forEach(token => { + if (token.indexOf("-") === 0) { + params.push({ + name : "_sort:desc", + value: token.substring(1) + }) + } + else { + params.push({ + name : "_sort:asc", + value: token + }) + } + }) + // params.push({ + // name : "_sort", + // value: this.sort + // }) + } + + if (!this.params._id) { + + // Min age ----------------------------------------------------- + if (this.minAge) { + let d = moment().subtract( + this.minAge.value, + this.minAge.units + ); + params.push({ + name : "birthdate", + value: "le" + d.format('YYYY-MM-DD') + }); + } + + // Max age ----------------------------------------------------- + if (this.maxAge) { + let d = moment().subtract( + this.maxAge.value, + this.maxAge.units + ); + params.push({ + name : "birthdate", + value: "ge" + d.format('YYYY-MM-DD') + }); + } + + // exclude deceased patients if age is specified --------------- + if (this.maxAge || this.minAge) { + let existing = params.find(p => p.name === "deceased"); + if (existing) { + existing.value = false; + } + else { + params.push({ + name : "deceased", + value: false + }); + } + } + + // Gender ------------------------------------------------------ + if (this.gender) { + params.push({ + name : "gender", + value: this.gender + }); + } + } + } + + // limit --------------------------------------------------------------- + if (this.limit) { + params.push({ + name : "_count", + value: this.limit + }); + } + + // offset -------------------------------------------------------------- + if (this.offset && this.cacheId) { + params.push({ + name: "_getpages", + value: this.cacheId + }, { + name : "_getpagesoffset", + value: this.offset + }); + } + + // Compile and return -------------------------------------------------- + return params.map(p => ( + encode ? + encodeURIComponent(p.name) + "=" + encodeURIComponent(p.value) : + p.name + "=" + p.value + )).join("&"); + } + + /** + * Checks if there are any conditions chosen at the moment + * @returns {Boolean} + */ + hasConditions() { + for (let key in this.conditions) { + if (this.conditions.hasOwnProperty(key)) { + return true; + } + } + return false; + } + + /** + * Compiles the current conditions into URL-encoded parameter list + * @returns {String} + */ + compileConditions() { + // let params = []; + + // for (let key in this.conditions) { + // let condition = this.conditions[key] + + // // system + // let value = []; + // for (let system in condition.codes) { + // let systemUrl = (CODE_SYSTEMS[system] || {}).url; + + // // system.code[n] - OR + // condition.codes[system].forEach(c => { + // value.push( systemUrl ? systemUrl + "|" + c : c ); + // }) + // } + + // if (value.length) { + // params.push({ + // name : "code", + // value: value.join(",") + // }) + // } + // } + + // return params.map(p => ( + // encodeURIComponent(p.name) + "=" + encodeURIComponent(p.value) + // )).join("&"); + let out = [] + for (let key in this.conditions) { + let condition = this.conditions[key] + + // system + let value = []; + for (let system in condition.codes) { + let systemUrl = (CODE_SYSTEMS[system] || {}).url || "http://snomed.info/sct"; + + // system.code[n] - OR + condition.codes[system].forEach(c => { + value.push(systemUrl + "|" + c); + }) + } + + if (value.length) { + out.push(value.join(",")); + } + } + return out.length ? "code=" + encodeURIComponent(out.join(",")) : ""; + } + + getConditionKeys() { + let out = [] + for (let key in this.conditions) { + let condition = this.conditions[key] + + // system + let value = []; + for (let system in condition.codes) { + let systemUrl = (CODE_SYSTEMS[system] || {}).url || "http://snomed.info/sct"; + + // system.code[n] - OR + condition.codes[system].forEach(c => { + value.push(systemUrl + "|" + c); + }) + } + + if (value.length) { + out.push(value.join(",")); + } + } + return out; + } + + /** + * Returns a promise resolved with a list of patient IDs that have the + * specified condition(s) + * @param {String} baseURL + * @returns {Promise} + */ + getPatientIDs(server) { + + if (this.__cache__.patientIDs) { + return Promise.resolve(this.__cache__.patientIDs); + } + + let conditions = this.compileConditions(); + + if (!conditions) { + return Promise.resolve([]); + } + + /** + * The keys (eg: "http://snomed.info/sct|44054006") that were set by the + * user. + * @type {Array} + * @private + */ + let conditionKeys = this.getConditionKeys(); + + /** + * Map of patient IDs as keys and array of condition keys as values. + * @private + */ + let patientIDs = {}; + + /** + * Handles the JSON response (single page) of the conditions query. + * Collects the patient IDs and their condition codes into the + * patientIDs local variable. When all the pages are fetched, cleans up + * the IDs to only contain those that have all the conditions specified + * by the user. + * @param {Object} response The JSON Conditions bundle response + * @returns {Promise} Array of patient ID strings (can be empty) + */ + const handleConditionsResponse = response => { + + // Collect the data + if (response.entry) { + response.entry.forEach(condition => { + let patientID = server.type == "DSTU-2" ? + condition.resource.patient.reference.split("/").pop(): + condition.resource.subject.reference.split("/").pop(); + if (!patientIDs[patientID]) { + patientIDs[patientID] = []; + } + patientIDs[patientID].push( + (getPath(condition, "resource.code.coding.0.system") || "http://snomed.info/sct") + + "|" + getPath(condition, "resource.code.coding.0.code") + ); + }); + + let nextLink = (response.link || []).find(l => l.relation == "next"); + if (nextLink) { + return request({ url: nextLink.url }).then(handleConditionsResponse); + } + } + // console.log(conditionKeys, patientIDs) + // Clean up and only leave patients having all the conditions + patientIDs = Object.keys(patientIDs).filter(key => { + return conditionKeys.every( + conditionKey => patientIDs[key].indexOf(conditionKey) > -1 + ); + }); + // console.log(patientIDs) + + // finally return a promise resolved with the compiled ID array + return Promise.resolve(patientIDs); + } + + // The conditions to search for + let params = [conditions]; + + // only need the patient - skip the rest to reduce the response + params.push( + server.type == "DSTU-2" ? + "_elements=patient,code" : + "_elements=subject,code" + ); + + // Set bigger limit here to reduce the chance of having to + // make other queries to fetch subsequent pages + params.push("_count=500"); + + // Tags (not currently available in STU2) + if (this.tags.length) { + params.push( "_tag=" + encodeURIComponent(this.tags.join(",")) ); + } + + return request({ + url: `${server.url}/Condition?${params.join("&")}` + }) + .then(handleConditionsResponse) + .then(ids => { + this.__cache__.patientIDs = ids; + return ids; + }); + } + + /** + * Fetches the patients matching the user-defined conditions. The actual + * strategy may vary but regardless of the implementation, a promise is + * returned that should eventually be resolved with the result bundle. + * @param {String} baseURL + * @returns {Promise} + */ + fetch(server) { + + let data = this.compile() + + // STU2 does not work with the deceased param + if (server.type == "DSTU-2") { + data = data.replace(/\bdeceased=(true|false)\b/gi, ""); + } + + // prepare the base options for the patient ajax request + let options = { + url: `${server.url}/Patient/_search`, + method: "POST", + processData: false, + data, + headers: { + accept: "application/json+fhir" + } + }; + + return this.getPatientIDs(server) + .then(ids => { + if (ids.length) { + // if IDs were found - add them to the patient query + options.data = [ + options.data, + "_id=" + encodeURIComponent(ids.join(",")) + ].filter(Boolean).join("&"); + } + else { + // If conditions were specified but no patients were found to + // have those conditions, then we should exit early. + if (this.hasConditions()) { + return Promise.reject( + "No patients found with the specified conditions!" + ); + } + } + return options; + }) + .then(request); + } +} diff --git a/src/lib/__tests__/PatientSearch.js b/src/lib/__tests__/PatientSearch.js new file mode 100644 index 0000000..72050f8 --- /dev/null +++ b/src/lib/__tests__/PatientSearch.js @@ -0,0 +1,475 @@ +/* global describe, it, chai */ + +import PatientSearch from "../PatientSearch" +import moment from "moment" + +const expect = chai.expect +// import { CODE_SYSTEMS } from "../constants" +import * as STU3 from "../../../build/config/stu3-open-sandbox.json5" +import * as STU2 from "../../../build/config/dstu2-open-sandbox.json5" + +const SERVERS = { + "STU2" : STU2.server, + "STU3" : STU3.server +}; + + +describe ("lib", () => { + describe ("PatientSearch", () => { + + it ("works with no params", () => { + let builder = new PatientSearch() + expect(builder.compile()).to.equal("") + }) + + it ("gender", next => { + let builder = new PatientSearch() + builder.setGender("male") + expect(builder.compile()).to.equal("gender=male") + builder.setGender("female") + expect(builder.compile()).to.equal("gender=female") + builder.setGender("x") + expect(builder.compile()).to.equal("gender=x") + builder.setGender(null) + expect(builder.compile()).to.equal("") + + let job = Promise.resolve(); + + job = job.then(() => { + builder.setGender("male"); + return builder.fetch(SERVERS.STU2).then(bundle => { + bundle.entry.forEach(patient => { + expect(patient.resource.gender).to.equal("male") + }) + }) + }); + + job = job.then(() => { + builder.setGender("male"); + return builder.fetch(SERVERS.STU3).then(bundle => { + bundle.entry.forEach(patient => { + expect(patient.resource.gender).to.equal("male") + }) + }) + }); + + job = job.then(() => { + builder.setGender("female"); + return builder.fetch(SERVERS.STU2).then(bundle => { + bundle.entry.forEach(patient => { + expect(patient.resource.gender).to.equal("female") + }) + }) + }); + + job = job.then(() => { + builder.setGender("female"); + return builder.fetch(SERVERS.STU3).then(bundle => { + bundle.entry.forEach(patient => { + expect(patient.resource.gender).to.equal("female") + }) + }) + }); + + job = job.then(() => next()); + job.catch(next); + }); + + // it ("Medical conditions", () => { + // let builder = new PatientSearch() + // builder.addCondition("hypertension", { + // description: 'Hypertension', + // codes: { + // 'SNOMED-CT' : ['38341003'] + // } + // }) + // expect(decodeURIComponent(builder.compile())).to.equal( + // `_has:Condition:patient:code=${CODE_SYSTEMS["SNOMED-CT"].url}|38341003` + // ) + + // builder.addCondition("diabetes", { + // description: 'Diabetes', + // codes: { + // 'SNOMED-CT' : ['44054006'] + // } + // }) + // expect(decodeURIComponent(builder.compile())).to.equal( + // `_has:Condition:patient:code=${ + // CODE_SYSTEMS["SNOMED-CT"].url + // }|38341003&_has:Condition:patient:code=${ + // CODE_SYSTEMS["SNOMED-CT"].url + // }|44054006` + // ) + + // builder.removeCondition("hypertension") + // expect(decodeURIComponent(builder.compile())).to.equal( + // `_has:Condition:patient:code=${ + // CODE_SYSTEMS["SNOMED-CT"].url + // }|44054006` + // ) + // }) + + it ("setMinAge", next => { + let builder = new PatientSearch() + let date = moment().subtract(2, "days").format("YYYY-MM-DD") + builder.setMinAge({ value: 2, units: "days" }) + + expect(decodeURIComponent(builder.compile())).to.equal( + "birthdate=le" + date + "&deceased=false" + ) + + let job = Promise.resolve(); + + job = job.then(() => { + builder.setMinAge({ value: 25, units: "years" }) + return builder.fetch(SERVERS.STU2).then(bundle => { + if (bundle.total > 0) { + bundle.entry.forEach(patient => { + expect( + moment() - moment(patient.resource.birthDate) >= 1000*60*60*24*365*25 + ).to.equal(true) + }) + } + }) + }); + + job = job.then(() => { + builder.setMinAge({ value: 25, units: "years" }) + return builder.fetch(SERVERS.STU3).then(bundle => { + if (bundle.total > 0) { + bundle.entry.forEach(patient => { + expect( + moment() - moment(patient.resource.birthDate) >= 1000*60*60*24*365*25 + ).to.equal(true) + }) + } + }) + }); + + job = job.then(() => next()); + job.catch(next); + }) + + it ("setMaxAge", next => { + let builder = new PatientSearch() + let date = moment().subtract(3, "months").format("YYYY-MM-DD") + builder.setMaxAge({ value: 3, units: "months" }) + + expect(decodeURIComponent(builder.compile())).to.equal( + "birthdate=ge" + date + "&deceased=false" + ) + + let job = Promise.resolve(); + + job = job.then(() => { + builder.setMaxAge({ value: 25, units: "years" }) + return builder.fetch(SERVERS.STU2).then(bundle => { + if (bundle.total > 0) { + bundle.entry.forEach(patient => { + expect( + moment() - moment(patient.resource.birthDate) <= 1000*60*60*24*365*25 + ).to.equal(true) + }) + } + }) + }); + + job = job.then(() => { + builder.setMaxAge({ value: 25, units: "years" }) + return builder.fetch(SERVERS.STU3).then(bundle => { + if (bundle.total > 0) { + bundle.entry.forEach(patient => { + expect( + moment() - moment(patient.resource.birthDate) <= 1000*60*60*24*365*25 + ).to.equal(true) + }) + } + }) + }); + + job = job.then(() => next()); + job.catch(next); + }) + + it ("setMinAge & setMaxAge", () => { + let builder = new PatientSearch() + let min = moment().subtract(2, "days").format("YYYY-MM-DD") + let max = moment().subtract(3, "months").format("YYYY-MM-DD") + builder.setMinAge({ value: 2, units: "days" }) + builder.setMaxAge({ value: 3, units: "months" }) + + expect(decodeURIComponent(builder.compile())).to.equal( + "birthdate=le" + min + "&birthdate=ge" + max + "&deceased=false" + ) + }) + + it ("setAgeGroup", () => { + let builder = new PatientSearch() + const before1year = moment().subtract(1, "years").format("YYYY-MM-DD") + const before18years = moment().subtract(18, "years").format("YYYY-MM-DD") + const before65years = moment().subtract(65, "years").format("YYYY-MM-DD") + + builder.setAgeGroup("infant") + expect(decodeURIComponent(builder.compile())).to.equal( + "birthdate=ge" + before1year + "&deceased=false" + ) + + builder.setAgeGroup("child") + expect(decodeURIComponent(builder.compile())).to.equal( + "birthdate=le" + before1year + + "&birthdate=ge" + before18years + "&deceased=false" + ) + + builder.setAgeGroup("adult") + expect(decodeURIComponent(builder.compile())).to.equal( + "birthdate=le" + before18years + + "&birthdate=ge" + before65years + "&deceased=false" + ) + + builder.setAgeGroup("elderly") + expect(decodeURIComponent(builder.compile())).to.equal( + "birthdate=le" + before65years + "&deceased=false" + ) + + builder.setAgeGroup(null) + expect(decodeURIComponent(builder.compile())).to.equal( + "" + ) + }) + + it ("setOffset", () => { + let builder = new PatientSearch() + builder.setOffset("x", 3) + expect(builder.compile()).to.equal("_getpages=x&_getpagesoffset=3") + builder.setOffset("x", -3) + expect(builder.compile()).to.equal("") + builder.setOffset("x", "whatever") + expect(builder.compile()).to.equal("") + }); + + it ("setLimit", () => { + let builder = new PatientSearch() + builder.setLimit(5) + expect(builder.compile()).to.equal("_count=5") + builder.setLimit(-5) + expect(builder.compile()).to.equal("") + builder.setLimit("whatever") + expect(builder.compile()).to.equal("") + }); + + it ("setQueryType", () => { + let builder = new PatientSearch() + builder.setQueryType("advanced") + builder.setLimit(5) + builder.setParam("a", "b") + expect(builder.queryType).to.equal("advanced") + expect(builder.compile()).to.equal("_count=5") + builder.setQueryType("whatever") + expect(builder.queryType).to.equal("default") + expect(builder.compile()).to.equal("a=b&_count=5") + }); + + it ("setQueryString", () => { + let builder = new PatientSearch() + builder.setQueryType("advanced").setQueryString("a=b&c=d&f") + expect(builder.compile()).to.equal("a=b&c=d&f=true") + }); + + it ("setSort", () => { + let builder = new PatientSearch() + builder.setSort("a,-b,c") + expect(decodeURIComponent(builder.compile())).to.equal( + "_sort:asc=a&_sort:desc=b&_sort:asc=c" + ) + }); + + it ("setParam", () => { + let builder = new PatientSearch() + expect(builder.params).to.deep.equal({}) + builder.setParam("a", 1) + expect(builder.params).to.deep.equal({ a: 1 }) + builder.setParam("a", 2) + expect(builder.params).to.deep.equal({ a: 2 }) + builder.setParam("b", 3) + expect(builder.params).to.deep.equal({ a: 2, b: 3 }) + expect(builder.compile()).to.equal("a=2&b=3") + builder.setParam("a", undefined) + expect(builder.params).to.deep.equal({ b: 3 }) + expect(builder.compile()).to.equal("b=3") + }); + + it ("hasParam", () => { + let builder = new PatientSearch() + expect(builder.params).to.deep.equal({}) + expect(builder.hasParam("a")).to.equal(false) + builder.setParam("a", 1) + expect(builder.params).to.deep.equal({ a: 1 }) + expect(builder.hasParam("a")).to.equal(true) + builder.setParam("b", 3) + expect(builder.hasParam("b")).to.equal(true) + builder.setParam("a", undefined) + expect(builder.hasParam("a")).to.equal(false) + expect(builder.hasParam("b")).to.equal(true) + }); + + it ("clone", () => { + const tpl = { + conditions : { a: 1, b: 2 }, + params : { c: 4 }, + minAge : { value: 2, units: "x" }, + maxAge : { value: 3, units: "y" }, + gender : "male", + limit : 8, + offset : 3, + cacheId : "cache", + ageGroup : "infant", + queryString: "x=y&z=-1", + queryType : "default" + }; + + let builder1 = (new PatientSearch()) + .setConditions(tpl.conditions) + .setParam("c", tpl.params.c) + .setAgeGroup(tpl.ageGroup) + .setMinAge(tpl.minAge) + .setMaxAge(tpl.maxAge) + .setGender(tpl.gender) + .setLimit(tpl.limit) + .setOffset(tpl.cacheId, tpl.offset) + .setQueryType(tpl.queryType) + .setQueryString(tpl.queryString); + + let builder2 = builder1.clone() + + expect(builder1.conditions).to.deep.equal(tpl.conditions) + expect(builder2.conditions).to.deep.equal(tpl.conditions) + + expect(builder1.params).to.deep.equal(tpl.params) + expect(builder2.params).to.deep.equal(tpl.params) + + expect(builder1.ageGroup).to.equal(tpl.ageGroup) + expect(builder2.ageGroup).to.equal(tpl.ageGroup) + + expect(builder1.minAge).to.deep.equal(tpl.minAge) + expect(builder2.minAge).to.deep.equal(tpl.minAge) + + expect(builder1.maxAge).to.deep.equal(tpl.maxAge) + expect(builder2.maxAge).to.deep.equal(tpl.maxAge) + + expect(builder1.gender).to.equal(tpl.gender) + expect(builder2.gender).to.equal(tpl.gender) + + expect(builder1.limit).to.equal(tpl.limit) + expect(builder2.limit).to.equal(tpl.limit) + + expect(builder1.cacheId).to.equal(tpl.cacheId) + expect(builder2.cacheId).to.equal(tpl.cacheId) + + expect(builder1.offset).to.equal(tpl.offset) + expect(builder2.offset).to.equal(tpl.offset) + + expect(builder1.queryType).to.equal(tpl.queryType) + expect(builder2.queryType).to.equal(tpl.queryType) + + expect(builder1.queryString).to.equal(tpl.queryString) + expect(builder2.queryString).to.equal(tpl.queryString) + + expect(builder1).to.not.equal(builder2) + expect(builder1.compile()).to.equal(builder2.compile()) + + }); + + it ("reset", () => { + let builder = (new PatientSearch()) + .setConditions({ a: 1, b: 2 }) + .setParam("c", 5) + .setAgeGroup("infant") + .setMinAge({ value: 2, units: "x" }) + .setMaxAge({ value: 3, units: "y" }) + .setGender("female") + .setLimit(3) + .setOffset("ddd", 33) + .setQueryType("default") + .setQueryString("whatever=something"); + + builder.reset(); + expect(builder.compile()).to.equal("") + }); + + it ("getState", () => { + let builder = (new PatientSearch()) + .setConditions({ a: 1, b: 2 }) + .setParam("c", 5) + .setAgeGroup("infant") + .setMinAge({ value: 2, units: "x" }) + .setMaxAge({ value: 3, units: "y" }) + .setGender("female") + .setLimit(3) + .setOffset("ddd", 33) + .setQueryType("default") + .setQueryString("whatever=something") + .setSort("-name"); + + expect(builder.getState()).to.deep.equal({ + conditions : { a: 1, b: 2 }, + params : { c: 5 }, + minAge : { value: 2, units: "x" }, + maxAge : { value: 3, units: "y" }, + gender : "female", + limit : 3, + offset : 33, + cacheId : "ddd", + ageGroup : "infant", + queryString: "whatever=something", + queryType : "default", + sort : "-name", + tags : [] + }) + }); + + describe ("UI dependencies", () => { + + it ("Resets the offset after changing the gender", () => { + let builder = new PatientSearch(); + builder.setOffset("x", 3); + expect(builder.compile()).to.equal("_getpages=x&_getpagesoffset=3") + builder.setGender("male") + expect(builder.compile()).to.equal("gender=male") + }); + + it ("Resets the offset after changing the conditions", () => { + let builder = new PatientSearch(); + builder.setOffset("x", 3); + expect(builder.compile()).to.equal("_getpages=x&_getpagesoffset=3") + builder.setConditions({}) + expect(builder.compile()).to.equal("") + }); + + it ("Resets the offset after changing the min age", () => { + let builder = new PatientSearch(); + builder.setOffset("x", 3); + expect(builder.compile()).to.equal("_getpages=x&_getpagesoffset=3") + builder.setMinAge({}) + expect(builder.compile().indexOf("_getpages=x&_getpagesoffset=3")).to.equal(-1) + }); + + it ("Resets the offset after changing the max age", () => { + let builder = new PatientSearch(); + builder.setOffset("x", 3); + expect(builder.compile()).to.equal("_getpages=x&_getpagesoffset=3") + builder.setMaxAge({}) + expect(builder.compile().indexOf("_getpages=x&_getpagesoffset=3")).to.equal(-1) + }); + + it ("Resets the offset after changing the age group", () => { + let builder = new PatientSearch(); + builder.setOffset("x", 3); + expect(builder.compile()).to.equal("_getpages=x&_getpagesoffset=3") + builder.setAgeGroup("adult") + expect(builder.compile().indexOf("_getpages=x&_getpagesoffset=3")).to.equal(-1) + }); + + + }); + }) +}) \ No newline at end of file diff --git a/src/lib/__tests__/index.js b/src/lib/__tests__/index.js new file mode 100644 index 0000000..d423009 --- /dev/null +++ b/src/lib/__tests__/index.js @@ -0,0 +1,120 @@ +/* global describe, it, chai */ +import moment from "moment" +import { + getPath, + getPatientAge, + getPatientName +} from ".." + +const expect = chai.expect + + + +describe ("lib", () => { + + //getPath ------------------------------------------------------------------ + describe ("getPath", () => { + describe ("Typical use", () => { + it (`finds shallow props`, () => { + expect(getPath({ a: 2, c: 4 }, "c")).to.equal(4); + }); + it (`finds nested props`, () => { + expect(getPath({ a: 2, b: { c: 5 }}, "b.c")).to.equal(5); + }); + it (`can work with arrays`, () => { + expect(getPath([ 2, 5 ], "1")).to.equal(5); + expect(getPath({ a: 2, b: [ { c: 6 } ] }, "b.0.c")).to.equal(6); + }); + it (`returns undefined for invalid paths`, () => { + expect(getPath([ 2, 5 ], "4")).to.equal(undefined); + expect(getPath([ 2, 5 ], "x")).to.equal(undefined); + expect(getPath([ 2, 5 ], "length")).to.equal(2); + expect(getPath({ + a: 2, + b: [{ c: 6}] + }, "b.0.c.r.5")).to.equal(undefined); + }); + }); + }); + + describe("getPatientAge", () => { + function test(birthDate, expected) { + expect(getPatientAge({ birthDate })).to.equal(expected) + } + it ("born now", () => { + test(moment(), "0 second") + }) + + it ("born a second ago", () => { + test(moment().subtract(1, 'seconds'), "1 second") + }) + + it ("born a minute ago", () => { + test(moment().subtract(1, 'minute'), "1 minute") + }) + + it ("born a hour ago", () => { + test(moment().subtract(1, 'hour'), "1 hour") + }) + + it ("born a day ago", () => { + test(moment().subtract(1, 'day'), "1 day") + }) + + it ("born a month ago", () => { + test(moment().subtract(1, 'month'), "1 month") + }) + + it ("born an year ago", () => { + test(moment().subtract(1, 'year'), "12 month") + }) + + it ("born 2 years ago", () => { + test(moment().subtract(2, 'year'), "2 year") + }) + + it ("born 3 years ago", () => { + test(moment().subtract(3, 'year'), "3 year") + }) + }); + + describe("getPatientName", () => { + it ("no patient", () => { + expect(getPatientName()).to.equal("") + }) + + it ("no name", () => { + expect(getPatientName({})).to.equal("") + }) + + it ("only family as string", () => { + expect(getPatientName({ name: [{ family: "family" }]})).to.equal("family") + }) + + it ("only family as array", () => { + expect(getPatientName({ name: [{ family: ["family ", " name "] }]})).to.equal("family name") + }) + + it ("everything as string", () => { + expect(getPatientName({ + name: [{ + family: " family ", + given : " given ", + prefix: " prefix ", + suffix: " suffix " + }] + })).to.equal("prefix given family suffix") + }) + + it ("everything as arrays", () => { + expect(getPatientName({ + name: [{ + family: [" family1 ", " family2 "], + given : [" given1 " , " given2 " ], + prefix: [" prefix1 ", " prefix2 "], + suffix: [" suffix1 ", " suffix2 "] + }] + })).to.equal("prefix1 prefix2 given1 given2 family1 family2 suffix1 suffix2") + }) + }) +}) \ No newline at end of file diff --git a/src/lib/constants.js b/src/lib/constants.js new file mode 100644 index 0000000..f7b9f46 --- /dev/null +++ b/src/lib/constants.js @@ -0,0 +1,9 @@ + +export const CODE_SYSTEMS = { + 'LOINC' : { url: 'http://loinc.org' }, + 'SNOMED-CT' : { url: 'http://snomed.info/sct' }, + 'RxNorm' : { url: 'http://www.nlm.nih.gov/research/umls/rxnorm' }, + 'CVX' : { url: 'http://hl7.org/fhir/sid/cvx' }, + 'NUBC' : { url: 'http://www.nubc.org/patient-discharge' }, + 'UMLS' : { url: 'http://uts.nlm.nih.gov/metathesaurus' } +}; \ No newline at end of file diff --git a/src/lib/index.js b/src/lib/index.js new file mode 100644 index 0000000..d9ed649 --- /dev/null +++ b/src/lib/index.js @@ -0,0 +1,464 @@ +import * as React from "react" +import moment from "moment" +import $ from "jquery" + +/** + * Returns the int representation of the first argument or the + * "defaultValue" if the int conversion is not possible. + * @param {number|string} x The argument to convert + * @param {number} defaultValue The fall-back return value. This will be + * converted to integer too. + * @return {Number} The resulting integer. + */ +export function intVal(x, defaultValue = 0) { + var out = parseInt(x + "", 10); + if (isNaN(out) || !isFinite(out)) { + out = intVal(defaultValue); + } + return out; +} + +/** + * Converts the argument to boolean value. "0" and "no" are recognized as false. + * "1" and "yes" are recognized as true. Everything else is just !!var + * @param {*} x The argument to convert + * @returns {Boolean} The argument converted to boolean + */ +export function boolVal(x) { + return (/^(false|0|no)$/i).test(String(x)) ? + false : + (/^(true|1|yes)$/i).test(String(x)) ? + true : + !!x; +} + +/** + * Given some input argument, this function tries to treat it like an error and + * extract an error message out of it. Supported input types are: + * - String + * - Error + * - AJAX JSON response + * - FHIR JSON response + * - Object map of error messages + * @param {*} input The input to analyse + * @param {String|JSX.Element} error(s) + */ +export function getErrorMessage(input) { + if (input && typeof input == "string") { + return input; + } + + let out = "Unknown error" + + if (input && input instanceof Error) { + out = String(input); + } + + else if (input && input.responseJSON) { + if (Array.isArray(input.responseJSON.issue) && input.responseJSON.issue.length) { + out = input.responseJSON.issue.map(o => o.diagnostics || "-").join("\n") + } + else { + out = ( + input.responseJSON.message || + input.responseJSON.error || + "Unknown error" + ) + } + } + + else if (input && input.responseText) { + out = ( input.responseText + " - " + input.statusText) || "Unknown error" + } + + else if (input && input.statusText) { + if (input.statusText == "timeout") { + out = "The server failed to respond in the desired number of seconds" + } + else { + out = input.statusText || "Unknown error" + } + } + + if (out && typeof out == "object") { + let out2 = [] + for (let key in (out)) { + out2.push(React.createElement("li", { key }, out[key])); + } + return React.createElement("div", null, [ + "Multiple errors", + React.createElement("ul", null, out2) + ]) + } + + return out +} + +/** + * Walks thru an object (ar array) and returns the value found at the provided + * path. This function is very simple so it intentionally does not support any + * argument polymorphism, meaning that the path can only be a dot-separated + * string. If the path is invalid returns undefined. + * @param {Object} obj The object (or Array) to walk through + * @param {String} path The path (eg. "a.b.4.c") + * @returns {*} Whatever is found in the path or undefined + */ +export function getPath(obj, path = "") { + return path.split(".").reduce((out, key) => out ? out[key] : undefined, obj) +} + +export function compileQueryString(params) { + let query = [] + + for (let key in params) { + let p = params[key] + + // If the parameter value is falsy (and other than 0) just don't include + // it in the query + if (!p && p !== 0) { + continue; + } + + // if the value is an array then include that parameter multiple times + // with different values + if (Array.isArray(p)) { + p.forEach(v => { + + // skip falsy not 0 values same as above + if (v || v === 0) { + query.push( + encodeURIComponent(key) + "=" + + encodeURIComponent(v) + ) + } + }) + } + + // Add normal param to the query string + else { + query.push( + encodeURIComponent(key) + "=" + + encodeURIComponent(p) + ) + } + } + + return query.join("&") +} + +export function parseQueryString(str) { + let out = {}; + str = String(str || "").trim().split("?").pop(); + str.split(/&/).forEach(pair => { + let tokens = pair.split("=") + let key = decodeURIComponent(tokens[0]) + if (key) { + let value = decodeURIComponent(tokens[1] || "true") + if (out.hasOwnProperty(key)) { + if (!Array.isArray(out[key])) { + out[key] = [out[key]] + } + out[key].push(value) + } + else { + out[key] = value + } + } + }) + return out +} + +export function setHashParam(name, value) { + let query = location.hash.split("?")[1] || ""; + let hash = location.hash.replace(/\?.*/, ""); + // console.warn(query) + query = parseQueryString(query || ""); + if (value === undefined) { + if (query.hasOwnProperty(name)) { + delete query[name] + } + } + else { + query[name] = value + } + + query = compileQueryString(query) + location.hash = hash + (query ? "?" + query : "") +} + +// Fhir parsing helpers -------------------------------------------------------- + +/** + * Given an array of Coding objects finds and returns the one that contains + * an MRN (using a code == "MR" check) + * @export + * @param {Object[]} codings Fhir.Coding[] + * @returns {Object} Fhir.Coding | undefined + */ +export function findMRNCoding(codings) { + if (Array.isArray(codings)) { + return codings.find(coding => coding.code == "MR"); + } +} + +/** + * Given an array of identifier objects finds and returns the one that contains an MRN + * @export + * @param {Object[]} identifiers + * @returns {Object} + */ +export function findMRNIdentifier(identifiers) { + return identifiers.find( + identifier => !!findMRNCoding(getPath(identifier, "type.coding")) + ); +} + +/** + * Given a patient returns his MRN + * @export + * @param {Object} patient + * @returns {string} + */ +export function getPatientMRN(patient) { + let mrn = null; + + if (Array.isArray(patient.identifier) && patient.identifier.length) { + mrn = findMRNIdentifier(patient.identifier); + if (mrn) { + return mrn.value; + } + } + + return mrn; +} + +/** + * Extracts and returns a human-readable name string from FHIR patient object. + * @param {Object} patient FHIR patient object + * @returns {String} Patient's name or an empty string + */ +export function getPatientName(patient) { + if (!patient) { + return "" + } + + let name = (patient.name || [])[0] + if (!name) { + return "" + } + + let family = Array.isArray(name.family) ? name.family : [ name.family ]; + let given = Array.isArray(name.given ) ? name.given : [ name.given ]; + let prefix = Array.isArray(name.prefix) ? name.prefix : [ name.prefix ]; + let suffix = Array.isArray(name.suffix) ? name.suffix : [ name.suffix ]; + + return [ + prefix.map(t => String(t || "").trim()).join(" "), + given .map(t => String(t || "").trim()).join(" "), + family.map(t => String(t || "").trim()).join(" "), + suffix.map(t => String(t || "").trim()).join(" ") + ].filter(Boolean).join(" "); +} + +/** + * Given a FHIR patient object, returns the patient's phone number (or empty string). + * Note that if the patient have multiple phones this will only return the first one. + * @param {Object} patient FHIR patient object + * @returns {String} Patient's phone or an empty string + */ +export function getPatientPhone(patient = {}) { + let phone = (patient.telecom || []).find(c => c.system == "phone"); + return phone ? phone.value : ""; +} + +/** + * Given a FHIR patient object, returns the patient's email (or an empty string). + * Note that if the patient have multiple emails this will only return the first one. + * @param {Object} patient FHIR patient object + * @returns {String} Patient's email address or an empty string + */ +export function getPatientEmail(patient = {}) { + let phone = (patient.telecom || []).find(c => c.system == "email"); + return phone ? phone.value : ""; +} + +/** + * Extracts and returns a human-readable address string from FHIR patient object. + * @param {Object} patient FHIR patient object + * @returns {String} Patient's address or an empty string + */ +export function getPatientHomeAddress(patient = {}) { + let a = (patient.address || []); + a = a.find(c => c.use == "home") || a[0] || {}; + return [a.line, a.postalCode, a.city, a.country].filter(Boolean).join(" "); +} + +/** + * Extracts and returns a human-readable age string from FHIR patient object. + * @param {Object} patient FHIR patient object + * @returns {String} Patient's age + */ +export function getPatientAge(patient) { + let from = moment(patient.birthDate); + let to = moment(patient.deceasedDateTime || undefined); + let age = to - from; + + let seconds = Math.round(age / 1000) + if (seconds < 60) { + return seconds + " second" + } + + let minutes = Math.round(seconds / 60) + if (minutes < 60) { + return minutes + " minute" + } + + let hours = Math.round(minutes / 60) + if (hours < 24) { + return hours + " hour" + } + + let days = Math.round(hours / 24) + if (days < 30) { + return days + " day" + } + + let months = Math.round(days / 30) + if (months < 24) { + return months + " month" + } + + let years = Math.round(days / 365) + return years + " year" +} + +/** + * Extracts and returns an URL pointing to the patient photo (or an empty string) + * @param {Object} patient FHIR patient object + * @returns {String} Patient's image URL + */ +export function getPatientImageUri(patient, base="") { + let data = getPath(patient, "photo.0.data") || ""; + let url = getPath(patient, "photo.0.url") || ""; + let type = getPath(patient, "photo.0.contentType") || ""; + if (url.indexOf("/") === 0) { + url = base + "" + url; + } + let http = url && url.match(/^https?:\/\//); + if (!http && data) { + if (type && data.indexOf("data:") !== 0) { + data = `data:${type};base64,${data}`; + } + url = data; + } + else if (type && !http) { + url = `data:${type};base64,${url}`; + } + return url; +} + +/** + * Given some input string (@input) and a string to search for (@query), this + * function will highlight all the occurrences by wrapping them in + * "" tag. + * @param {String} input The input string + * @param {String} query The string to search for + * @param {Boolean} caseSensitive Defaults to false + * @returns {String} The input string with the matches highlighted + */ +export function searchHighlight(input, query, caseSensitive = false) { + let s = String(input); + let q = String(query); + let x = s; + + if (!caseSensitive) { + x = x.toLowerCase(); + q = q.toLowerCase(); + } + + let i = x.indexOf(q); + + if (i > -1) { + return ( + s.substr(0, i) + + `` + + s.substr(i, q.length) + + "" + + s.substr(i + q.length) + ); + } + + return input; +} + +/** + * Uses searchHighlight() to generate and return a JSX.span element containing + * the @html string with @search highlighted + * @param {String} html The input string + * @param {String} search The string to search for + * @returns {JSX.span} SPAN element with the matches highlighted + */ +export function renderSearchHighlight(html, search) { + return ( + + ) +} + +/** + * Given a fhir bundle fins it's link having the given rel attribute. + * @param {Object} bundle FHIR JSON Bundle object + * @param {String} rel The rel attribute to look for: prev|next|self... (see + * http://www.iana.org/assignments/link-relations/link-relations.xhtml#link-relations-1) + * @returns {String|null} Returns the url of the link or null if the link was + * not found. + */ +export function getBundleURL(bundle, rel) { + let nextLink = bundle.link; + if (nextLink) { + nextLink = nextLink.find(l => l.relation === rel); + return nextLink && nextLink.url ? nextLink.url : null + } + return null; +} + +export function request(options) { + options = typeof options == "string" ? { url : options } : options || {}; + let cfg = $.extend(true, options, { + headers: { + Accept: "application/json+fhir" + } + }) + + return new Promise((resolve, reject) => { + // console.info("Requesting " + decodeURIComponent(cfg.url)) + $.ajax(cfg).then( + resolve, + xhr => { + let message = getErrorMessage(xhr) + if (message && typeof message == "string") { + return reject(new Error(message)) + } + else { + return reject({ message }) + } + } + ) + }) +} + +export function getAllPages(options, result = []) { + return request(options).then(bundle => { + (bundle.entry || []).forEach(item => { + if (item.fullUrl && result.findIndex(o => (o.fullUrl === item.fullUrl)) == -1) { + result.push(item); + } + }) + let nextUrl = getBundleURL(bundle, "next"); + if (nextUrl) { + return getAllPages({ ...options, url: nextUrl }, result); + } + return result; + }); +} diff --git a/src/redux/__tests__/settings.js b/src/redux/__tests__/settings.js new file mode 100644 index 0000000..880b4ca --- /dev/null +++ b/src/redux/__tests__/settings.js @@ -0,0 +1,83 @@ +/* global describe, beforeEach, it, chai */ +import store from ".." +import { + merge, + showSelectedOnly, + replace +} from "../settings" + +const expect = chai.expect + +describe ("Store", () => { + describe ("settings", () => { + + const BASE_SETTINGS = { + loaded: true + }; + + beforeEach(() => { + store.dispatch(replace({})) + }) + + describe ("merge", () => { + + it ("Works wih empty object", () => { + store.dispatch(merge({})) + expect(store.getState().settings).to.deep.equal(BASE_SETTINGS) + }) + + it ("Works wih invalid arguments", () => { + store.dispatch(merge(2)) + expect(store.getState().settings).to.deep.equal(BASE_SETTINGS) + + store.dispatch(merge(false)) + expect(store.getState().settings).to.deep.equal(BASE_SETTINGS) + + store.dispatch(merge(new Date())) + expect(store.getState().settings).to.deep.equal(BASE_SETTINGS) + }) + + it ("Works as expected and des deep merge", () => { + store.dispatch(merge({ a: { b: 2 } })) + expect(store.getState().settings).to.deep.equal({ + ...BASE_SETTINGS, + a: { + b: 2 + } + }) + store.dispatch(merge({ a: { b: 3, c: 4 } })) + expect(store.getState().settings).to.deep.equal({ + ...BASE_SETTINGS, + a: { + b: 3, + c: 4 + } + }) + }) + }) + + describe("showSelectedOnly", () => { + it ("Works with boolean", () => { + store.dispatch(showSelectedOnly(true)) + expect(store.getState().settings).to.deep.equal({ + renderSelectedOnly: true + }) + store.dispatch(showSelectedOnly(false)) + expect(store.getState().settings).to.deep.equal({ + renderSelectedOnly: false + }) + }) + + it ("Works with non-boolean args", () => { + store.dispatch(showSelectedOnly("yes")) + expect(store.getState().settings).to.deep.equal({ + renderSelectedOnly: true + }) + store.dispatch(showSelectedOnly(0)) + expect(store.getState().settings).to.deep.equal({ + renderSelectedOnly: false + }) + }) + }) + }) +}) \ No newline at end of file diff --git a/src/redux/index.js b/src/redux/index.js new file mode 100644 index 0000000..6b2fc68 --- /dev/null +++ b/src/redux/index.js @@ -0,0 +1,45 @@ +/** + * The purpose of this file is to combine reducers and create the single store. + * One should use it simply like so: + * + * ```import STORE from "./redux"``` + */ +import { createStore, applyMiddleware, combineReducers } from "redux" +import thunk from "redux-thunk" +import selection from "./selection" +import query from "./query" +import settings from "./settings" +import urlParams from "./urlParams" + +const middleWares = [thunk] + +// Create logger middleware that will log all redux action but only +// use that in development env. +if (process.env.NODE_ENV == "development" && console && console.groupCollapsed) { + let logger = _store => next => action => { + let result; + if (!action.__no_log) { + console.groupCollapsed(action.type) + console.info("dispatching", action) + result = next(action) + console.log("next state", _store.getState()) + console.groupEnd(action.type) + } + else { + result = next(action) + } + return result + } + + middleWares.push(logger) +} + +export default createStore( + combineReducers({ + selection, + query, + settings, + urlParams + }), + applyMiddleware(...middleWares) +); diff --git a/src/redux/query.js b/src/redux/query.js new file mode 100644 index 0000000..dc2e619 --- /dev/null +++ b/src/redux/query.js @@ -0,0 +1,215 @@ +import { createAction, handleActions } from "redux-actions" +import PatientSearch from "../lib/PatientSearch" +import { + parseQueryString, + getBundleURL +} from "../lib" + +export const queryBuilder = new PatientSearch( + parseQueryString(window.location.hash.split("?")[1] || "") +); + +/** + * The initial state is just an empty object that will be filled + * with query parameters. + */ +const INITIAL_STATE = { + ...queryBuilder.getState(), + loading: false, + error : null, + bundle : null +} + +// Private action constants +const SET_LOADING = "app/search/SET_LOADING" +const SET_ERROR = "app/search/SET_ERROR" +const SET_DATA = "app/search/SET_DATA" +const SET_MIN_AGE = "app/search/SET_MIN_AGE" +const SET_MAX_AGE = "app/search/SET_MAX_AGE" +const SET_AGE_GROUP = "app/search/SET_AGE_GROUP" +const ADD_CONDITION = "app/search/ADD_CONDITION" +const DEL_CONDITION = "app/search/DEL_CONDITION" +const ADD_TAG = "app/search/ADD_TAG" +const DEL_TAG = "app/search/DEL_TAG" +const SET_CONDITIONS = "app/search/SET_CONDITIONS" +const SET_GENDER = "app/search/SET_GENDER" +const SET_PARAM = "app/search/SET_PARAM" +const SET_QUERY_STRING = "app/search/SET_QUERY_STRING" +const SET_QUERY_TYPE = "app/search/SET_QUERY_TYPE" +const SET_SORT = "app/search/SET_SORT" +const SET_TAGS = "app/search/SET_TAGS" +const SET_LIMIT = "app/search/SET_LIMIT" + + +// Create (and export) the redux actions --------------------------------------- +export const setLoading = createAction(SET_LOADING) +export const setError = createAction(SET_ERROR) +export const setData = createAction(SET_DATA) +export const setMinAge = createAction(SET_MIN_AGE) +export const setMaxAge = createAction(SET_MAX_AGE) +export const setAgeGroup = createAction(SET_AGE_GROUP) +export const addCondition = createAction(ADD_CONDITION) +export const delCondition = createAction(DEL_CONDITION) +export const setConditions = createAction(SET_CONDITIONS) +export const setGender = createAction(SET_GENDER) +export const setParam = createAction(SET_PARAM) +export const setQueryString = createAction(SET_QUERY_STRING) +export const setQueryType = createAction(SET_QUERY_TYPE) +export const setSort = createAction(SET_SORT) +export const addTag = createAction(ADD_TAG) +export const delTag = createAction(DEL_TAG) +export const setTags = createAction(SET_TAGS) +export const setLimit = createAction(SET_LIMIT) + + +export const fetch = function() { + return (dispatch, getState) => { + dispatch(setLoading(true)) + dispatch(setError(null)) + const { settings } = getState() + queryBuilder.fetch(settings.server).then( + bundle => { + dispatch(setData(bundle)) + dispatch(setLoading(false)) + }, + function(error) { + dispatch(setError(error)) + dispatch(setLoading(false)) + } + ) + } +} + +export const goNext = function() { + return (dispatch, getState) => { + const { bundle } = getState().query; + let url = getBundleURL(bundle, "next"); + if (url) { + url = parseQueryString(url); + queryBuilder.setOffset(url._getpages, +url._getpagesoffset); + dispatch(fetch()); + } + } +} + +export const goPrev = function() { + return (dispatch, getState) => { + const { bundle } = getState().query; + let url = getBundleURL(bundle, "previous"); + if (url) { + url = parseQueryString(url); + queryBuilder.setOffset(url._getpages, +url._getpagesoffset); + dispatch(fetch()); + } + } +} + + + + +// Export the reducer as default +export default handleActions({ + + [SET_SORT]: (state, action) => { + queryBuilder.setSort(action.payload) + return { ...state, ...queryBuilder.getState() } + }, + + [SET_QUERY_STRING]: (state, action) => { + queryBuilder.setQueryString(action.payload) + return { ...state, ...queryBuilder.getState() } + }, + + [SET_QUERY_TYPE]: (state, action) => { + queryBuilder.setQueryType(action.payload) + return { ...state, ...queryBuilder.getState() } + }, + + [SET_PARAM]: (state, action) => { + queryBuilder.setParam(action.payload.name, action.payload.value) + return { ...state, ...queryBuilder.getState() } + }, + + [SET_MIN_AGE]: (state, action) => { + queryBuilder.setMinAge(action.payload) + return { ...state, ...queryBuilder.getState() } + }, + + [SET_MAX_AGE]: (state, action) => { + queryBuilder.setMaxAge(action.payload) + return { ...state, ...queryBuilder.getState() } + }, + + [SET_AGE_GROUP]: (state, action) => { + queryBuilder.setAgeGroup(action.payload) + return { ...state, ...queryBuilder.getState() } + }, + + [ADD_CONDITION]: (state, action) => { + queryBuilder.addCondition(action.payload.key, action.payload.value) + return { ...state, ...queryBuilder.getState() } + }, + + [SET_CONDITIONS]: (state, action) => { + queryBuilder.setConditions(action.payload) + return { ...state, ...queryBuilder.getState() } + }, + + [DEL_CONDITION]: (state, action) => { + queryBuilder.removeCondition(action.payload) + return { ...state, ...queryBuilder.getState() } + }, + + [ADD_TAG]: (state, action) => { + queryBuilder.addTag(action.payload) + return { ...state, ...queryBuilder.getState() } + }, + + [DEL_TAG]: (state, action) => { + queryBuilder.removeTag(action.payload) + return { ...state, ...queryBuilder.getState() } + }, + + [SET_TAGS]: (state, action) => { + queryBuilder.setTags(action.payload) + return { ...state, ...queryBuilder.getState() } + }, + + [SET_GENDER]: (state, action) => { + queryBuilder.setGender(action.payload) + return { ...state, ...queryBuilder.getState() } + }, + + [SET_LIMIT]: (state, action) => { + queryBuilder.setLimit(action.payload) + return { ...state, ...queryBuilder.getState() } + }, + + /** + * Sets the loading state of the query + */ + [SET_LOADING]: (state, action) => ({ + ...state, + ...queryBuilder.getState(), + loading: !!action.payload + }), + + /** + * Sets the error state of the query + */ + [SET_ERROR]: (state, action) => ({ + ...state, + ...queryBuilder.getState(), + error: action.payload + }), + + /** + * Sets the data fetched by the query + */ + [SET_DATA]: (state, action) => ({ + ...state, + ...queryBuilder.getState(), + bundle: action.payload + }) + +}, INITIAL_STATE) diff --git a/src/redux/selection.js b/src/redux/selection.js new file mode 100644 index 0000000..620872d --- /dev/null +++ b/src/redux/selection.js @@ -0,0 +1,85 @@ +import { createAction, handleActions } from "redux-actions" +import { setHashParam, parseQueryString, boolVal } from "../lib" + +const PARAMS = parseQueryString(location.hash) +const SINGLE = boolVal(PARAMS["single-selection"]) + +/** + * For the initial selection state parse the query portion of the URL hash (if + * any). Note that this is not enough! The hash query can provide list of + * patient IDs but we initialize them as true. Later they should be updated to + * contain actual patient objects instead of true! + */ +let initialSelection = {}; +(PARAMS._selection || "").split(",").forEach(k => { + if (k) { + + // In single mode the last id will become the selected one! + if (SINGLE) { + initialSelection = { + [k]: true + }; + } + else { + initialSelection[k] = true; + } + } +}) + +/** + * The initial state is just an empty object that will be filled + * with patient IDs as keys and booleans as values to indicate if + * that patient is selected. + */ +const INITIAL_STATE = { ...initialSelection } + +// Private action constants +const TOGGLE = "app/selection/TOGGLE" +const SET_ALL = "app/selection/SET_ALL" + +// Create (and export) the redux actions +export const toggle = createAction(TOGGLE) +export const setAll = createAction(SET_ALL) + +/** + * Update the "_selection" hash parameter whenever the selection changes + * @param {Object} selection The selection to set + */ +function setHashSelection(selection) { + setHashParam( + "_selection", + Object.keys(selection).filter( + k => selection.hasOwnProperty(k) && !!selection[k] + ).join(",") + ) +} + +// Export the reducer as default +export default handleActions({ + + /** + * Toggle the selected state of action.payload.id + */ + [TOGGLE]: (state, action) => { + let newState = { + [action.payload.id]: state[action.payload.id] ? false : action.payload + }; + + if (!SINGLE) { + newState = { ...state, ...newState } + } + setHashSelection(newState) + return newState + }, + + /** + * Set the entire selection. Useful if an initial selection + * needs to be provided from the host application. + */ + [SET_ALL]: (_, action) => { + let newState = { ...action.payload } + setHashSelection(newState) + return newState + } + +}, INITIAL_STATE) diff --git a/src/redux/settings.js b/src/redux/settings.js new file mode 100644 index 0000000..675ab2d --- /dev/null +++ b/src/redux/settings.js @@ -0,0 +1,30 @@ +import { createAction, handleActions } from "redux-actions" +import INITIAL_STATE from "../config.default.js" + +// Private action constants +const MERGE = "app/settings/MERGE" +const REPLACE = "app/settings/REPLACE" +const RENDER_SELECTED_ONLY = "app/settings/RENDER_SELECTED_ONLY" + +// Create (and export) the redux actions +export const merge = createAction(MERGE) +export const replace = createAction(REPLACE) +export const showSelectedOnly = createAction(RENDER_SELECTED_ONLY) + +// Export the reducer as default +export default handleActions({ + + [MERGE]: (state, action) => ({ + ...state, + ...action.payload, + loaded: true + }), + + [RENDER_SELECTED_ONLY]: (state, action) => ({ + ...state, + renderSelectedOnly: !!action.payload + }), + + // Replace the entire state (useful for testing) + [REPLACE]: (_, action) => ({ ...action.payload }) +}, INITIAL_STATE) diff --git a/src/redux/urlParams.js b/src/redux/urlParams.js new file mode 100644 index 0000000..f531b44 --- /dev/null +++ b/src/redux/urlParams.js @@ -0,0 +1,16 @@ +import { parseQueryString } from "../lib" + +/** + * The initial state is just an empty object that will be filled + * with patient IDs as keys and booleans as values to indicate if + * that patient is selected. + */ +const INITIAL_STATE = parseQueryString( + location.hash.replace(/^.*\?/, "") +) + + +// Export the reducer as default +export default function() { + return { ...INITIAL_STATE } +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..ac52184 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,208 @@ +"use strict"; + +const Path = require("path"); +const Webpack = require("webpack"); +const Package = require("./package.json"); +const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +const ENV = process.env.NODE_ENV || "production"; +const SRC = Path.join(__dirname, "src"); +const DST = Path.join(__dirname, "build"); +const PORT = process.env.PORT || 9001; +const HOST = process.env.HOST || "0.0.0.0"; + +let config = { + + context: SRC, + + entry: { + index : [ + Path.join(SRC, "index.js") + ], + "vendor": [ + "react", + "react-dom", + "jquery", + "redux", + "redux-actions", + "react-redux", + "redux-thunk", + "react-router", + "react-router-dom", + "history", + "moment" + ] + }, + + output: { + filename : "[name].js", + path : `${DST}/js/`, + publicPath: "/js/", + sourceMapFilename: "[file].[hash].map" + }, + + module: { + rules: [ + { + test : /\.less$/, + include: [ SRC ], + use: [ + "style-loader?singleton", + "css-loader", + "postcss-loader", + "less-loader" + ] + } + ] + }, + + // When importing a module whose path matches one of the following, just + // assume a corresponding global variable exists and use that instead. + // This is important because it allows us to avoid bundling all of our + // dependencies, which allows browsers to cache those libraries between builds. + // externals: { + // "react" : "React", + // "react-dom": "ReactDOM" + // }, + + resolve : { + extensions : [ ".js", ".jsx", ".less" ] + }, + + devtool: "#source-map", + + stats: { + colors : true, + modules : true, + reasons : true, + errorDetails: true + }, + + plugins : [ + new Webpack.DefinePlugin({ + "process.env.NODE_ENV": "'" + ENV + "'", + "__APP_VERSION__" : "'" + Package.version + "'" + }), + new Webpack.optimize.CommonsChunkPlugin({ + name : "vendor", + filename : "commons.js", + minChunks: 2 + }), + new HtmlWebpackPlugin({ + filename: DST + "/index.html", + template: SRC + "/index.ejs", + inject : false, + Webpack + }) + ] +} + +if (ENV == "development") { + config.devServer = { + contentBase : DST, + historyApiFallback: true, + hot : true, + // inline : true, + quiet : false, + noInfo : false, + clientLogLevel : "warning", + publicPath: `http://${HOST}:${PORT}/js/`, + // publicPath: "/js/", + + // Display only errors to reduce the amount of output. + stats: { + + // Add asset Information + assets: true, + // Sort assets by a field + assetsSort: "field", + // Add information about cached (not built) modules + cached: true, + // Add children information + children: true, + // Add chunk information (setting this to `false` allows for a less verbose output) + chunks: true, + // Add built modules information to chunk information + chunkModules: false, + // Add the origins of chunks and chunk merging info + chunkOrigins: false, + // Sort the chunks by a field + chunksSort: "field", + // Context directory for request shortening + context: ".", + // `webpack --colors` equivalent + colors: true, + // Add errors + errors: true, + // Add details to errors (like resolving log) + errorDetails: true, + // Add the hash of the compilation + hash: true, + // Add built modules information + modules: false, + // Sort the modules by a field + modulesSort: "field", + // Add public path information + publicPath: true, + // Add information about the reasons why modules are included + reasons: true, + // Add the source code of modules + source: true, + // Add timing information + timings: true, + // Add webpack version information + version: true, + // Add warnings + warnings: true + }, + watchOptions: { + ignored: /node_modules/ + }, + host: HOST, + port: PORT + }; + + config.module.rules.push({ + test : /\.jsx?$/, + include: [ SRC ], + // exclude: [/node_modules/], + use : [ "react-hot-loader", "babel-loader" ] + }); + + config.plugins.push( + new Webpack.HotModuleReplacementPlugin(), + + // prints more readable module names in the browser console + // on HMR updates + new Webpack.NamedModulesPlugin() + ); + + config.entry.index = [ + "webpack-dev-server/client?http://localhost:" + PORT, + "webpack/hot/only-dev-server", // "only" prevents reload on syntax errors + "./index.js" + ]; + + config.output.publicPath = "http://localhost:" + PORT + "/js/"; +} + +else if (ENV == "production") { + config.plugins.push( + new Webpack.optimize.UglifyJsPlugin({ + sourceMap: true + }), + new BundleAnalyzerPlugin({ + analyzerMode: "static", + openAnalyzer: false + }) + ); + + config.module.rules.push({ + test : /\.jsx?$/, + include: [ SRC ], + use : [ "babel-loader" ] + }); +} + +module.exports = config;