├── empty-module.js
├── public
├── robots.txt
├── google1bd62c15ea25e3b9.html
├── favicon.ico
├── og-image.png
├── img
│ ├── alder.png
│ ├── alder.webp
│ ├── eieform.png
│ ├── eieform.webp
│ ├── flytting.png
│ ├── flytting.webp
│ ├── levekaar.png
│ ├── levekaar.webp
│ ├── utdanning.png
│ ├── boligpriser.png
│ ├── boligpriser.webp
│ ├── folkemengde.png
│ ├── folkemengde.webp
│ ├── innvandring.png
│ ├── innvandring.webp
│ ├── trangboddhet.png
│ ├── utdanning.webp
│ ├── bygningstyper.png
│ ├── bygningstyper.webp
│ ├── husholdninger.png
│ ├── husholdninger.webp
│ ├── trangboddhet.webp
│ ├── kommunale-boliger.png
│ └── kommunale-boliger.webp
├── favicon-16x16.png
├── favicon-32x32.png
├── apple-touch-icon.png
├── mstile-150x150.png
├── Oslo-logo-morkeblaa-RGB.png
├── android-chrome-192x192.png
├── android-chrome-384x384.png
├── browserconfig.xml
└── site.webmanifest
├── .env
├── src
├── util
│ ├── graph-templates
│ │ ├── graph-helpers
│ │ │ ├── rowHelpers
│ │ │ │ ├── _rowHelpers.js
│ │ │ │ ├── createBars.js
│ │ │ │ ├── getRowName.js
│ │ │ │ ├── index.js
│ │ │ │ ├── tooltipHandler.js
│ │ │ │ ├── styleValueText.js
│ │ │ │ ├── updateBars.js
│ │ │ │ ├── styleRowName.js
│ │ │ │ ├── styleRows.js
│ │ │ │ └── initRows.js
│ │ │ ├── closeButton
│ │ │ │ ├── index.js
│ │ │ │ ├── showOrHide.js
│ │ │ │ └── init.js
│ │ │ ├── boxplotHelpers
│ │ │ │ ├── _legendConfig.js
│ │ │ │ ├── positionLegend.js
│ │ │ │ ├── rowMedianText.js
│ │ │ │ ├── rowFill.js
│ │ │ │ ├── rowGeography.js
│ │ │ │ ├── rowMeanRect.js
│ │ │ │ ├── rowMeanText.js
│ │ │ │ ├── rowDivider.js
│ │ │ │ ├── rowMedianRect.js
│ │ │ │ ├── rowBox.js
│ │ │ │ ├── index.js
│ │ │ │ ├── createRowElements.js
│ │ │ │ └── createBoxPlotLegend.js
│ │ │ ├── drawVoronoi
│ │ │ │ ├── enterVoronoiCell.js
│ │ │ │ ├── styleTexts.js
│ │ │ │ ├── styleCircles.js
│ │ │ │ ├── generateVoronoiData.js
│ │ │ │ ├── styleRects.js
│ │ │ │ ├── index.js
│ │ │ │ └── drawPaths.js
│ │ │ ├── columnHelpers
│ │ │ │ ├── positionColumns.js
│ │ │ │ ├── initColumns.js
│ │ │ │ ├── updateColSubheading.js
│ │ │ │ ├── updateColHeading.js
│ │ │ │ ├── index.js
│ │ │ │ ├── updateColFill.js
│ │ │ │ ├── createColumns.js
│ │ │ │ ├── updateColArrow.js
│ │ │ │ └── updateClickTrigger.js
│ │ │ ├── resizeSvg.js
│ │ │ ├── index.js
│ │ │ ├── sortData.js
│ │ │ └── generateTableData.js
│ │ ├── colors.js
│ │ └── locale.js
│ ├── config.js
│ ├── index.js
│ ├── polyfills.js
│ ├── downloadFile.js
│ ├── debounce.js
│ ├── tableToExcel.js
│ ├── tooltip.js
│ ├── positionLabels.js
│ ├── downloadSvg.js
│ ├── downloadPng.js
│ └── tableToCsv.js
├── styles
│ ├── fonts
│ │ ├── OsloSans-Bold.woff
│ │ ├── OsloSans-Light.woff
│ │ ├── OsloSans-Medium.woff
│ │ └── OsloSans-Regular.woff
│ ├── _animations.scss
│ ├── _variables.scss
│ ├── _colors.scss
│ ├── _fonts.scss
│ ├── _typography.scss
│ ├── _print.scss
│ ├── _table.scss
│ ├── _layout.scss
│ └── main.scss
├── i18n.js
├── config
│ ├── topics
│ │ ├── dataSources.js
│ │ ├── befolkningsutvikling.js
│ │ ├── utdanning.js
│ │ ├── fodteDodeFlytting.js
│ │ ├── alder.js
│ │ └── eierform.js
│ ├── ageRanges.js
│ ├── predefinedOptions.js
│ ├── geoData
│ │ ├── districts.js
│ │ ├── sagene.js
│ │ └── st_hanshaugen.js
│ ├── allDistricts.js
│ ├── topics.js
│ └── districtNames.js
├── directives
│ └── clickOutside.js
├── __tests__
│ ├── __snapshots__
│ │ └── app.spec.js.snap
│ └── app.spec.js
├── components
│ ├── __tests__
│ │ ├── __snapshots__
│ │ │ ├── navigationTopbar.spec.js.snap
│ │ │ ├── VCategory.spec.js.snap
│ │ │ └── graphInstance.spec.js.snap
│ │ ├── VCategory.spec.js
│ │ ├── graphInstance.spec.js
│ │ ├── navigationDrawer.spec.js
│ │ ├── navigationTopbar.spec.js
│ │ └── graphCard.spec.js
│ ├── OkIcon.vue
│ ├── UxSignals.vue
│ ├── TheFooter.vue
│ ├── VCategory.vue
│ ├── PktIconsSprite.vue
│ ├── Modal.vue
│ └── OsloLogo.vue
├── views
│ ├── __tests__
│ │ ├── notFound.spec.js
│ │ ├── district.spec.js
│ │ ├── topic.spec.js
│ │ └── __snapshots__
│ │ │ └── notFound.spec.js.snap
│ ├── Topic.vue
│ ├── District.vue
│ └── NotFound.vue
├── main.js
├── assets
│ └── d3
│ │ └── index.js
├── router.js
└── store.js
├── .dockerignore
├── postcss.config.cjs
├── .eslintignore
├── .prettierrc
├── setup_file.js
├── .editorconfig
├── svgTransform.js
├── babel.config.cjs
├── .gitignore
├── vite.config.js
├── Dockerfile
├── server
├── package.json
├── auth
│ ├── api.js
│ ├── accessToken.js
│ └── index.js
├── server.js
└── routes.js
├── .github
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── PULL_REQUEST_TEMPLATE
│ └── pull_request_template.md
└── workflows
│ ├── pull_request.yml
│ ├── nodejs.yml
│ └── codeql-analysis.yml
├── jest.config.cjs
├── punkt.config.json
├── LICENSE
├── README.md
├── .stylelintrc.cjs
├── .eslintrc.cjs
├── .circleci
└── config.yml
├── package.json
├── index.html
└── tests
└── MockStore.js
/empty-module.js:
--------------------------------------------------------------------------------
1 | module.exports = '';
2 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow:
3 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | VITE_I18N_LOCALE=nb-no
2 | VITE_I18N_FALLBACK_LOCALE=nb-no
3 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/rowHelpers/_rowHelpers.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/google1bd62c15ea25e3b9.html:
--------------------------------------------------------------------------------
1 | google-site-verification: google1bd62c15ea25e3b9.html
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/og-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/og-image.png
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | .idea
3 | *.iml
4 | build
5 | npm-debug.log
6 | node_modules/
7 | stats.json
8 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | autoprefixer: {},
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/public/img/alder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/alder.png
--------------------------------------------------------------------------------
/public/img/alder.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/alder.webp
--------------------------------------------------------------------------------
/public/img/eieform.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/eieform.png
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/img/eieform.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/eieform.webp
--------------------------------------------------------------------------------
/public/img/flytting.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/flytting.png
--------------------------------------------------------------------------------
/public/img/flytting.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/flytting.webp
--------------------------------------------------------------------------------
/public/img/levekaar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/levekaar.png
--------------------------------------------------------------------------------
/public/img/levekaar.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/levekaar.webp
--------------------------------------------------------------------------------
/public/img/utdanning.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/utdanning.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/img/boligpriser.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/boligpriser.png
--------------------------------------------------------------------------------
/public/img/boligpriser.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/boligpriser.webp
--------------------------------------------------------------------------------
/public/img/folkemengde.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/folkemengde.png
--------------------------------------------------------------------------------
/public/img/folkemengde.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/folkemengde.webp
--------------------------------------------------------------------------------
/public/img/innvandring.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/innvandring.png
--------------------------------------------------------------------------------
/public/img/innvandring.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/innvandring.webp
--------------------------------------------------------------------------------
/public/img/trangboddhet.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/trangboddhet.png
--------------------------------------------------------------------------------
/public/img/utdanning.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/utdanning.webp
--------------------------------------------------------------------------------
/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/mstile-150x150.png
--------------------------------------------------------------------------------
/public/img/bygningstyper.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/bygningstyper.png
--------------------------------------------------------------------------------
/public/img/bygningstyper.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/bygningstyper.webp
--------------------------------------------------------------------------------
/public/img/husholdninger.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/husholdninger.png
--------------------------------------------------------------------------------
/public/img/husholdninger.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/husholdninger.webp
--------------------------------------------------------------------------------
/public/img/trangboddhet.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/trangboddhet.webp
--------------------------------------------------------------------------------
/public/img/kommunale-boliger.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/kommunale-boliger.png
--------------------------------------------------------------------------------
/public/Oslo-logo-morkeblaa-RGB.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/Oslo-logo-morkeblaa-RGB.png
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-384x384.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/android-chrome-384x384.png
--------------------------------------------------------------------------------
/public/img/kommunale-boliger.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/public/img/kommunale-boliger.webp
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /build/
2 | /config/
3 | /dist/
4 | /docs/
5 | /src/coverageReport
6 | /src/config/geodata
7 | /server/server.js
8 |
--------------------------------------------------------------------------------
/src/styles/fonts/OsloSans-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/src/styles/fonts/OsloSans-Bold.woff
--------------------------------------------------------------------------------
/src/styles/fonts/OsloSans-Light.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/src/styles/fonts/OsloSans-Light.woff
--------------------------------------------------------------------------------
/src/styles/fonts/OsloSans-Medium.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/src/styles/fonts/OsloSans-Medium.woff
--------------------------------------------------------------------------------
/src/styles/fonts/OsloSans-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslokommune/bydelsfakta/HEAD/src/styles/fonts/OsloSans-Regular.woff
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/closeButton/index.js:
--------------------------------------------------------------------------------
1 | import init from './init';
2 | import showOrHide from './showOrHide';
3 |
4 | export { init, showOrHide };
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "trailingComma": "es5",
4 | "singleQuote": true,
5 | "printWidth": 120,
6 | "semi": true,
7 | "arrowParens": "always"
8 | }
9 |
--------------------------------------------------------------------------------
/src/util/config.js:
--------------------------------------------------------------------------------
1 | export const baseUrl = import.meta.env.PROD ? '' : '';
2 |
3 | export const apiUrl = import.meta.env.PROD ? window.location.origin : 'http://localhost:5000';
4 |
--------------------------------------------------------------------------------
/setup_file.js:
--------------------------------------------------------------------------------
1 | function noOp() {}
2 |
3 | if (typeof window.URL.createObjectURL === 'undefined') {
4 | Object.defineProperty(window.URL, 'createObjectURL', { value: noOp });
5 | }
6 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/svgTransform.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | process() {
3 | return 'module.exports = {};';
4 | },
5 | getCacheKey() {
6 | // The output is always the same.
7 | return 'svgTransform';
8 | },
9 | };
10 |
--------------------------------------------------------------------------------
/src/styles/_animations.scss:
--------------------------------------------------------------------------------
1 | .fade-enter-active,
2 | .fade-leave-active {
3 | transition: opacity 0.5s;
4 | }
5 |
6 | .fade-enter-from,
7 | .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
8 | opacity: 0;
9 | }
10 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/boxplotHelpers/_legendConfig.js:
--------------------------------------------------------------------------------
1 | const boxWidth = 180;
2 | const boxHeight = 20;
3 | const textLabels = ['25. prosentil', 'Median', '75. prosentil'];
4 |
5 | export { boxWidth, boxHeight, textLabels };
6 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/rowHelpers/createBars.js:
--------------------------------------------------------------------------------
1 | export default function (selection) {
2 | return selection
3 | .selectAll('rect.bar')
4 | .data((d) => d.values)
5 | .join('rect')
6 | .attr('class', 'bar');
7 | }
8 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/drawVoronoi/enterVoronoiCell.js:
--------------------------------------------------------------------------------
1 | export default function (enter) {
2 | const g = enter.append('g');
3 | g.append('path');
4 | g.append('rect');
5 | g.append('circle');
6 | g.append('text');
7 | return g;
8 | }
9 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/columnHelpers/positionColumns.js:
--------------------------------------------------------------------------------
1 | export default function positionColumns(selection) {
2 | selection
3 | .transition()
4 | .duration(this.duration)
5 | .attr('transform', (d, i) => `translate(${this.x[i](0)},0)`);
6 | }
7 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/rowHelpers/getRowName.js:
--------------------------------------------------------------------------------
1 | export default function (d) {
2 | if (this.isMobileView && d.values.length) {
3 | return `${d.geography} (${this.format(d.values[0][this.method], this.method)})`;
4 | }
5 | return d.geography;
6 | }
7 |
--------------------------------------------------------------------------------
/src/styles/_variables.scss:
--------------------------------------------------------------------------------
1 | $break-xs: 340px;
2 | $break-sm: 480px;
3 | $break-md: 768px;
4 | $break-lg: 1024px;
5 | $break-xl: 1200px;
6 |
7 | $sidebarWidth: 20rem;
8 |
9 | $font-small: 0.9rem;
10 | $font-body: 1rem;
11 | $font-medium: 1.15rem;
12 | $font-large: 1.45rem;
13 | $font-huge: 2.25rem;
14 |
--------------------------------------------------------------------------------
/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #b3f5ff
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/resizeSvg.js:
--------------------------------------------------------------------------------
1 | export default function resizeSvg(selection) {
2 | selection
3 | .transition()
4 | .attr('height', this.padding.top + this.height + this.padding.bottom + this.sourceHeight)
5 | .attr('width', this.padding.left + this.width + this.padding.right);
6 | }
7 |
--------------------------------------------------------------------------------
/src/util/index.js:
--------------------------------------------------------------------------------
1 | import allDistricts from '../config/allDistricts';
2 | import { topics } from '../config/topics';
3 |
4 | export const getDistrictName = (uri) => allDistricts.find((district) => district.uri === uri).value;
5 |
6 | export const getHumanReadableTopic = (id) => (topics[id] ? topics[id].text : '');
7 |
--------------------------------------------------------------------------------
/babel.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: ['@babel/preset-env'],
3 | plugins: [
4 | function () {
5 | return {
6 | visitor: {
7 | MetaProperty(path) {
8 | path.replaceWithSourceString('process');
9 | },
10 | },
11 | };
12 | },
13 | ],
14 | };
15 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/closeButton/showOrHide.js:
--------------------------------------------------------------------------------
1 | //
2 | export default function showOrHide() {
3 | this.close
4 | .style('display', () => (this.selected > -1 ? 'block' : 'none'))
5 | .attr('transform', `translate(${this.width - 30}, -60)`)
6 | .attr('tabindex', this.selected === -1 ? false : 0);
7 | }
8 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/rowHelpers/index.js:
--------------------------------------------------------------------------------
1 | import initRows from './initRows';
2 | import styleRows from './styleRows';
3 | import createBars from './createBars';
4 | import updateBars from './updateBars';
5 | import handleTooltips from './tooltipHandler';
6 |
7 | export { initRows, styleRows, createBars, updateBars, handleTooltips };
8 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/columnHelpers/initColumns.js:
--------------------------------------------------------------------------------
1 | import createColumns from './createColumns';
2 |
3 | export default function () {
4 | return this.canvas
5 | .select('g.columns')
6 | .selectAll('g.column')
7 | .data(this.filteredData.meta.series, (d) => d.heading + d.subheading)
8 | .join(createColumns);
9 | }
10 |
--------------------------------------------------------------------------------
/src/i18n.js:
--------------------------------------------------------------------------------
1 | import { createI18n } from 'vue-i18n';
2 | import nb from './locales/nb-no.json';
3 |
4 | const i18n = createI18n({
5 | locale: import.meta.env.VITE_I18N_LOCALE || 'nb-no',
6 | fallbackLocale: import.meta.env.VITE_I18N_FALLBACK_LOCALE || 'nb-no',
7 | messages: {
8 | 'nb-no': nb,
9 | },
10 | });
11 |
12 | export default i18n;
13 |
--------------------------------------------------------------------------------
/src/config/topics/dataSources.js:
--------------------------------------------------------------------------------
1 | export default {
2 | ssb: {
3 | name: 'Statistisk sentralbyrå',
4 | url: 'https://ssb.no/',
5 | },
6 | oslo: {
7 | name: 'Oslo kommune',
8 | url: 'https://statistikkbanken.oslo.kommune.no/',
9 | },
10 | boligmappa: {
11 | name: 'Boligmappa AS',
12 | url: 'https://www.hjemla.no',
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/rowHelpers/tooltipHandler.js:
--------------------------------------------------------------------------------
1 | import { showTooltipOver, showTooltipMove, hideTooltip } from '@/util/tooltip';
2 |
3 | export default function (selection) {
4 | selection
5 | .on('mouseover', (e, d) => showTooltipOver(this.format(d[this.method], this.method)))
6 | .on('mousemove', showTooltipMove)
7 | .on('mouseleave', hideTooltip);
8 | }
9 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/boxplotHelpers/positionLegend.js:
--------------------------------------------------------------------------------
1 | import { boxWidth } from './_legendConfig';
2 |
3 | export default function (selection) {
4 | const offsetX = -this.padding.left + (this.padding.left + this.width + this.padding.right) - (boxWidth + 120);
5 | const offsetY = this.height + 30;
6 |
7 | selection.attr('transform', `translate(${[offsetX, offsetY]})`);
8 | }
9 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/drawVoronoi/styleTexts.js:
--------------------------------------------------------------------------------
1 | export default function (selection, dataAccessor) {
2 | selection
3 | .select('text')
4 | .attr('x', (d) => d.data.x + this.x.bandwidth() / 2 + 4)
5 | .attr('y', (d) => d.data.y - 8)
6 | .text(dataAccessor)
7 | .attr('opacity', 0)
8 | .style('pointer-events', 'none')
9 | .style('cursor', 'auto');
10 | }
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 | /docs
5 | /src/coverageReport
6 | /tests/e2e/videos/
7 | /tests/e2e/screenshots/
8 |
9 | # local env files
10 | .env.local
11 | .env.*.local
12 |
13 | # Log files
14 | npm-debug.log*
15 | yarn-debug.log*
16 | yarn-error.log*
17 |
18 | # Editor directories and files
19 | .idea
20 | .vscode
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw*
26 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/drawVoronoi/styleCircles.js:
--------------------------------------------------------------------------------
1 | export default function (selection) {
2 | selection
3 | .select('circle')
4 | .attr('r', 6)
5 | .attr('cx', (d) => d.data.x + this.x.bandwidth() / 2)
6 | .attr('cy', (d) => d.data.y)
7 | .attr('opacity', 0)
8 | .style('pointer-events', 'none')
9 | .attr('stroke', 'white')
10 | .attr('stroke-width', 2);
11 | }
12 |
--------------------------------------------------------------------------------
/src/util/graph-templates/colors.js:
--------------------------------------------------------------------------------
1 | import { interpolateRdBu } from 'd3';
2 |
3 | export const color = {
4 | yellow: '#F8C66B',
5 | light_yellow: '#F8F0DC',
6 | purple: '#292858',
7 | grey: '#cccccc',
8 | light_grey: '#F0F1F1',
9 | red: '#FF8174',
10 | blue: '#6EE9FF',
11 | positive: interpolateRdBu(0.9),
12 | negative: interpolateRdBu(0.1),
13 | };
14 |
15 | export const interpolator = interpolateRdBu;
16 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import vue from '@vitejs/plugin-vue';
3 | import path from 'path';
4 |
5 | export default defineConfig({
6 | plugins: [vue()],
7 | build: {
8 | outDir: 'docs',
9 | sourcemap: true,
10 | },
11 | resolve: {
12 | alias: [
13 | {
14 | find: '@',
15 | replacement: path.resolve(__dirname, 'src'),
16 | },
17 | ],
18 | },
19 | });
20 |
--------------------------------------------------------------------------------
/src/directives/clickOutside.js:
--------------------------------------------------------------------------------
1 | export default {
2 | beforeMount: (el, binding) => {
3 | el.clickOutsideEvent = (event) => {
4 | if (!(el === event.target || el.contains(event.target))) {
5 | binding.value();
6 | }
7 | };
8 | document.body.addEventListener('click', el.clickOutsideEvent);
9 | },
10 | unmounted: (el) => {
11 | document.body.removeEventListener('click', el.clickOutsideEvent);
12 | },
13 | };
14 |
--------------------------------------------------------------------------------
/src/util/polyfills.js:
--------------------------------------------------------------------------------
1 | // IE11 polyfill for 'forEach' on nodelists
2 | if ('NodeList' in window && !NodeList.prototype.forEach) {
3 | NodeList.prototype.forEach = function (callback, thisArg) {
4 | thisArg = thisArg || window;
5 | for (let i = 0; i < this.length; i += 1) {
6 | callback.call(thisArg, this[i], i, this);
7 | }
8 | };
9 | }
10 |
11 | if (typeof SVGElement.prototype.blur === 'undefined') {
12 | SVGElement.prototype.blur = function () {};
13 | }
14 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine@sha256:928b24aaadbd47c1a7722c563b471195ce54788bf8230ce807e1dd500aec0549
2 |
3 | WORKDIR /usr/src/app/
4 | COPY ./docs ./docs
5 | COPY ./server ./server
6 |
7 | WORKDIR /usr/src/app/server
8 | RUN npm install --production
9 |
10 | WORKDIR /usr/src/app
11 |
12 | EXPOSE 5000
13 |
14 | RUN addgroup -S app
15 | RUN adduser -S -D -H -G app app
16 | RUN chown -R app:app /usr/src/app/
17 | USER app
18 |
19 | CMD [ "node", "server/server.js", "--loglevel", "info" ]
20 |
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-384x384.png",
12 | "sizes": "384x384",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#B3F5FF",
17 | "background_color": "#B3F5FF",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/columnHelpers/updateColSubheading.js:
--------------------------------------------------------------------------------
1 | import util from '../../template-utils';
2 |
3 | export default function (selection) {
4 | selection
5 | .select('text.colSubheading')
6 | .text((d, i) => util.truncate(d.subheading, this.x[i].range()))
7 | .style('display', () => (this.filteredData.meta.series.length > 1 || this.selected > -1 ? 'inherit' : 'none'))
8 | .attr('opacity', (d, i) =>
9 | i === this.highlight || this.highlight === -1 || this.highlight === undefined ? 1 : 0.2
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/index.js:
--------------------------------------------------------------------------------
1 | import * as closeButton from './closeButton';
2 | import * as rowHelpers from './rowHelpers';
3 | import * as boxplotHelpers from './boxplotHelpers';
4 | import * as columnHelpers from './columnHelpers';
5 | import drawVoronoi from './drawVoronoi';
6 |
7 | import resizeSvg from './resizeSvg';
8 | import sortData from './sortData';
9 | import generateTableData from './generateTableData';
10 |
11 | export { closeButton, rowHelpers, resizeSvg, columnHelpers, sortData, generateTableData, boxplotHelpers, drawVoronoi };
12 |
--------------------------------------------------------------------------------
/src/__tests__/__snapshots__/app.spec.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`App shallowmounts app 1`] = `
4 |
25 | `;
26 |
--------------------------------------------------------------------------------
/src/styles/_colors.scss:
--------------------------------------------------------------------------------
1 | $color-blue: #6ee9ff;
2 | $color-light-blue: #b3f5ff;
3 | $color-light-blue-2: #b2d2d8;
4 | $color-purple: #292858;
5 | $color-yellow: #f8c66b;
6 | $color-red: #ff8174;
7 |
8 | $color-bg: #f3f3f5;
9 | $color-border: #dae3e5;
10 |
11 | $color-grey-50: #f7f7f7;
12 | $color-grey-100: #e1e1e6;
13 | $color-grey-200: #c6c6cc;
14 | $color-grey-300: #adadb3;
15 | $color-grey-400: #939399;
16 | $color-grey-500: #797980;
17 | $color-grey-600: #5e5e66;
18 | $color-grey-700: #43434d;
19 | $color-grey-800: #242433;
20 | $color-grey-900: #0e0e1a;
21 |
22 | $color-link: #0075eb;
23 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "server.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "start": "node server.js"
9 | },
10 | "engines": {
11 | "node": "20"
12 | },
13 | "author": "",
14 | "license": "ISC",
15 | "dependencies": {
16 | "axios": "^1.6.0",
17 | "cheerio": "^1.0.0-rc.10",
18 | "compression": "^1.7.4",
19 | "cors": "^2.8.5",
20 | "date-fns": "^2.22.1",
21 | "express": "^4.18.2",
22 | "morgan": "^1.10.0"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/boxplotHelpers/rowMedianText.js:
--------------------------------------------------------------------------------
1 | export function initRowMedianText(selection) {
2 | selection
3 | .attr('class', 'median-value')
4 | .attr('text-anchor', 'start')
5 | .attr('font-size', 12)
6 | .attr('font-weight', 700)
7 | .attr('y', this.rowHeight / 2 + 5);
8 | }
9 |
10 | export function updateRowMedianText(selection) {
11 | selection
12 | .select('.median-value')
13 | .text((d) => `${d.median} år`)
14 | .transition()
15 | .duration(this.duration)
16 | .attr('x', (d) => this.gapX + this.width1 + this.x2(d.median) + 6);
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/navigationTopbar.spec.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`TheNavigationTopbar renders correctly 1`] = `
4 |
7 |
13 |
16 |
17 |
23 |
24 |
25 |
26 |
27 | `;
28 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/boxplotHelpers/rowFill.js:
--------------------------------------------------------------------------------
1 | import { color } from '@/util/graph-templates/colors';
2 |
3 | export function initRowFill(selection) {
4 | selection
5 | .attr('class', 'rowFill')
6 | .attr('fill', color.purple)
7 | .attr('height', this.rowHeight)
8 | .attr('x', -this.padding.left);
9 | }
10 |
11 | export function updateRowFill(selection) {
12 | selection
13 | .select('.rowFill')
14 | .transition()
15 | .duration(this.duration)
16 | .attr('fill-opacity', (d) => (d.avgRow || d.totalRow ? 0.05 : 0))
17 | .attr('width', this.width + this.padding.left + this.padding.right);
18 | }
19 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/columnHelpers/updateColHeading.js:
--------------------------------------------------------------------------------
1 | import util from '@/util/graph-templates/template-utils';
2 |
3 | export default function (selection) {
4 | selection
5 | .select('text.colHeading')
6 | .style('display', () => (this.filteredData.meta.series.length > 1 || this.selected > -1 ? 'inherit' : 'none'))
7 | .text((d, i) => {
8 | const colWidth = this.x[i].range()[1] - this.x[i].range()[0];
9 | return util.truncate(d.heading, colWidth);
10 | })
11 | .attr('opacity', (d, i) =>
12 | i === this.highlight || this.highlight === -1 || this.highlight === undefined ? 1 : 0.2
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/boxplotHelpers/rowGeography.js:
--------------------------------------------------------------------------------
1 | import { color } from '@/util/graph-templates/colors';
2 | import util from '@/util/graph-templates/template-utils';
3 |
4 | export function initRowGeography(selection) {
5 | selection
6 | .attr('class', 'geography')
7 | .attr('fill', color.purple)
8 | .attr('y', this.rowHeight / 2 + 6)
9 | .attr('x', -this.padding.left + 10);
10 | }
11 |
12 | export function updateRowGeography(selection) {
13 | selection
14 | .select('.geography')
15 | .attr('font-weight', (d) => (d.avgRow || d.totalRow ? 700 : 400))
16 | .text((d) => util.truncate(d.geography, this.padding.left));
17 | }
18 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/boxplotHelpers/rowMeanRect.js:
--------------------------------------------------------------------------------
1 | import { color } from '@/util/graph-templates/colors';
2 |
3 | export function initMeanRect(selection) {
4 | selection
5 | .attr('class', 'mean-stroke')
6 | .attr('fill', color.purple)
7 | .attr('height', this.rowHeight)
8 | .attr('width', 3)
9 | .attr('shape-rendering', 'geometricPrecision')
10 | .attr('transform', `translate(-1.5, 0)`)
11 | .attr('y', 0);
12 | }
13 |
14 | export function updateRowMeanRect(selection) {
15 | selection
16 | .select('.mean-stroke')
17 | .transition()
18 | .duration(this.duration)
19 | .attr('x', (d) => this.x(d.mean));
20 | }
21 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/boxplotHelpers/rowMeanText.js:
--------------------------------------------------------------------------------
1 | import { norwegianLocale } from '@/util/graph-templates/locale';
2 |
3 | export function initMeanText(selection) {
4 | selection
5 | .attr('class', 'mean-value')
6 | .attr('text-anchor', 'start')
7 | .attr('font-size', 12)
8 | .attr('font-weight', 700)
9 | .attr('y', this.rowHeight / 2 + 5);
10 | }
11 |
12 | export function updateRowMeanText(selection) {
13 | selection
14 | .select('.mean-value')
15 | .text((d) => `${norwegianLocale.format(',.2f')(d.mean)} år`)
16 | .transition()
17 | .duration(this.duration)
18 | .attr('x', (d) => this.x(d.mean) + 6);
19 | }
20 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/boxplotHelpers/rowDivider.js:
--------------------------------------------------------------------------------
1 | import { color } from '@/util/graph-templates/colors';
2 |
3 | export function initRowDivider(selection) {
4 | selection
5 | .attr('class', 'divider')
6 | .attr('fill', color.purple)
7 | .attr('x', -this.padding.left)
8 | .attr('height', 1)
9 | .attr('y', this.rowHeight);
10 | }
11 |
12 | export function updateRowDivider(selection) {
13 | selection
14 | .select('.divider')
15 | .transition()
16 | .duration(this.duration)
17 | .attr('fill-opacity', (d) => (d.avgRow || d.totalRow ? 0.5 : 0.2))
18 | .attr('width', this.width + this.padding.left + this.padding.right);
19 | }
20 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/columnHelpers/index.js:
--------------------------------------------------------------------------------
1 | import createColumns from './createColumns';
2 | import initColumns from './initColumns';
3 | import positionColumns from './positionColumns';
4 | import updateClickTrigger from './updateClickTrigger';
5 | import updateColHeading from './updateColHeading';
6 | import updateColSubheading from './updateColSubheading';
7 | import updateColFill from './updateColFill';
8 | import updateColArrow from './updateColArrow';
9 |
10 | export {
11 | createColumns,
12 | initColumns,
13 | positionColumns,
14 | updateClickTrigger,
15 | updateColHeading,
16 | updateColSubheading,
17 | updateColFill,
18 | updateColArrow,
19 | };
20 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/drawVoronoi/generateVoronoiData.js:
--------------------------------------------------------------------------------
1 | import { voronoi } from 'd3-voronoi';
2 |
3 | export default function (data, shape) {
4 | // TODO: The `d3-voronoi` module is now obsolete, and d3@6 bundles the `d3-delaunay` module instead.
5 | // It can still be loaded as an independent module, but should be replaced by `d3-delaunay`. The
6 | // API, algorithms, and data structure have been changed.
7 | // See https://observablehq.com/@d3/d3v6-migration-guide#delaunay.
8 | return voronoi()
9 | .extent([[1, 1], shape])
10 | .x((d) => d.x + this.x.bandwidth() / 2)
11 | .y((d) => d.y)
12 | .polygons(data)
13 | .filter(Boolean);
14 | }
15 |
--------------------------------------------------------------------------------
/src/styles/_fonts.scss:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-weight: 400;
3 | font-family: 'OsloSans';
4 | font-style: normal;
5 | font-stretch: normal;
6 | src: url('./fonts/OsloSans-Regular.woff') format('woff');
7 | font-display: auto;
8 | }
9 |
10 | @font-face {
11 | font-weight: 700;
12 | font-family: 'OsloSans';
13 | font-style: normal;
14 | font-stretch: normal;
15 | src: url('./fonts/OsloSans-Bold.woff') format('woff');
16 | font-display: auto;
17 | }
18 |
19 | @font-face {
20 | font-weight: 500;
21 | font-family: 'OsloSans';
22 | font-style: normal;
23 | font-stretch: normal;
24 | src: url('./fonts/OsloSans-Medium.woff') format('woff');
25 | font-display: auto;
26 | }
27 |
--------------------------------------------------------------------------------
/src/util/downloadFile.js:
--------------------------------------------------------------------------------
1 | export default function (content, filename, filetype) {
2 | const url = URL.createObjectURL(content);
3 | const downloadLink = document.createElement('a');
4 | if (window.navigator.msSaveOrOpenBlob) {
5 | downloadLink.href = '#';
6 | downloadLink.download = '';
7 | downloadLink.addEventListener('click', () => window.navigator.msSaveOrOpenBlob(content, `${filename}${filetype}`));
8 | downloadLink.click();
9 | } else {
10 | downloadLink.href = url;
11 | downloadLink.download = `${filename}${filetype}`;
12 | document.body.appendChild(downloadLink);
13 | downloadLink.click();
14 | document.body.removeChild(downloadLink);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/drawVoronoi/styleRects.js:
--------------------------------------------------------------------------------
1 | import { color } from '@/util/graph-templates/colors';
2 | import util from '@/util/graph-templates/template-utils';
3 |
4 | export default function (selection, dataAccessor) {
5 | selection
6 | .select('rect')
7 | .attr('height', 25)
8 | .attr('data-value', dataAccessor)
9 | .attr('width', (d, i, j) => util.getTextWidth(j[i].dataset.value) + 12)
10 | .attr('x', (d) => d.data.x + this.x.bandwidth() / 2 - 2)
11 | .attr('y', (d) => d.data.y - 26)
12 | .attr('fill', color.yellow)
13 | .attr('stroke', 'white')
14 | .attr('rx', 12.5)
15 | .attr('opacity', 0)
16 | .style('pointer-events', 'none');
17 | }
18 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/boxplotHelpers/rowMedianRect.js:
--------------------------------------------------------------------------------
1 | import { color } from '@/util/graph-templates/colors';
2 |
3 | export function initRowMedianRect(selection) {
4 | selection
5 | .attr('class', 'median-stroke')
6 | .attr('fill', color.purple)
7 | .attr('height', this.rowHeight)
8 | .attr('width', 3)
9 | .attr('shape-rendering', 'geometricPrecision')
10 | .attr('transform', `translate(-1.5, 0)`)
11 | .attr('y', 0);
12 | }
13 |
14 | export function updateRowMedianRect(selection) {
15 | selection
16 | .select('.median-stroke')
17 | .transition()
18 | .duration(this.duration)
19 | .attr('x', (d) => this.gapX + this.width1 + this.x2(d.median));
20 | }
21 |
--------------------------------------------------------------------------------
/src/config/ageRanges.js:
--------------------------------------------------------------------------------
1 | export default [
2 | { range: '', label: '-- Velg --', selected: true, disabled: true },
3 | { range: '[0, 1]', label: '0–1 år' },
4 | { range: '[2, 5]', label: '2–5 år' },
5 | { range: '[6, 12]', label: '6–12 år' },
6 | { range: '[13, 15]', label: '13–15 år' },
7 | { range: '[16, 18]', label: '16–18 år' },
8 | { range: '[19, 29]', label: '19–29 år' },
9 | { range: '[30, 39]', label: '30–39 år' },
10 | { range: '[40, 49]', label: '40–49 år' },
11 | { range: '[50, 59]', label: '50–59 år' },
12 | { range: '[60, 66]', label: '60–66 år' },
13 | { range: '[67, 79]', label: '67–79 år' },
14 | { range: '[80, 89]', label: '80–89 år' },
15 | { range: '[90, 119]', label: '90+ år' },
16 | ];
17 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: "[REQUEST]"
5 | labels: enhancement
6 | assignees: aulonm
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/src/styles/_typography.scss:
--------------------------------------------------------------------------------
1 | @use './variables' as *;
2 | @use './colors' as *;
3 |
4 | h2.section-heading {
5 | width: 100%;
6 | margin-bottom: 0.5rem;
7 | padding: 0.5rem 1rem 0;
8 | color: $color-purple;
9 | font-weight: 500;
10 | font-size: $font-large;
11 |
12 | @media screen and (min-width: $break-md) {
13 | padding: 1.5rem 1rem 0.5rem;
14 | }
15 | }
16 |
17 | h3 {
18 | margin-bottom: 0.5em;
19 | font-weight: 500;
20 | }
21 |
22 | a {
23 | text-decoration: none;
24 | }
25 |
26 | svg a:not([href$='#']) {
27 | text-decoration: underline;
28 | }
29 |
30 | .tick text {
31 | color: $color-grey-600;
32 | font-weight: 500;
33 | font-size: $font-small;
34 | font-family: monospace;
35 | text-rendering: geometricPrecision;
36 | }
37 |
--------------------------------------------------------------------------------
/src/util/debounce.js:
--------------------------------------------------------------------------------
1 | // Returns a function, that, as long as it continues to be invoked, will not
2 | // be triggered. The function will be called after it stops being called for
3 | // N milliseconds. If `immediate` is passed, trigger the function on the
4 | // leading edge, instead of the trailing.
5 | export default function (func, wait, immediate) {
6 | let timeout;
7 | return function () {
8 | const context = this;
9 | const args = arguments;
10 | const later = function () {
11 | timeout = null;
12 | if (!immediate) func.apply(context, args);
13 | };
14 | const callNow = immediate && !timeout;
15 | clearTimeout(timeout);
16 | timeout = setTimeout(later, wait);
17 | if (callNow) func.apply(context, args);
18 | };
19 | }
20 |
--------------------------------------------------------------------------------
/jest.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | testEnvironment: 'jsdom',
3 | testEnvironmentOptions: {
4 | url: 'http://localhost/',
5 | customExportConditions: ['node', 'node-addons'],
6 | },
7 | rootDir: './',
8 | moduleDirectories: ['node_modules', 'src'],
9 | moduleFileExtensions: ['js', 'vue'],
10 | transform: {
11 | '^.+\\.(ts|tsx|js|jsx)$': 'babel-jest',
12 | '^.+\\.vue$': '@vue/vue3-jest',
13 | },
14 | transformIgnorePatterns: ['node_modules/(?!axios|d3|d3-array|internmap|delaunator|robust-predicates)'],
15 | moduleNameMapper: {
16 | '^@/(.*)$': '/src/$1',
17 | },
18 | setupFiles: ['/setup_file.js'],
19 | globals: {
20 | 'vue-jest': {
21 | compilerOptions: {
22 | comments: false,
23 | },
24 | },
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/boxplotHelpers/rowBox.js:
--------------------------------------------------------------------------------
1 | import { color } from '@/util/graph-templates/colors';
2 |
3 | export function initRowBox(selection) {
4 | selection
5 | .attr('class', 'box')
6 | .attr('fill', color.purple)
7 | .attr('stroke', color.purple)
8 | .attr('stroke-width', 1)
9 | .attr('fill-opacity', 0.2)
10 | .attr('rx', 2)
11 | .attr('shape-rendering', 'geometricPrecision')
12 | .attr('height', this.barHeight)
13 | .attr('y', (this.rowHeight - this.barHeight) / 2);
14 | }
15 |
16 | export function updateRowBox(selection) {
17 | selection
18 | .select('.box')
19 | .transition()
20 | .duration(this.duration)
21 | .attr('x', (d) => this.gapX + this.width1 + this.x2(d.low))
22 | .attr('width', (d) => this.x2(d.high) - this.x2(d.low));
23 | }
24 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/drawVoronoi/index.js:
--------------------------------------------------------------------------------
1 | import generateVoronoiData from './generateVoronoiData';
2 | import enterVoronoiCell from './enterVoronoiCell';
3 | import styleCircles from './styleCircles';
4 | import styleRects from './styleRects';
5 | import styleTexts from './styleTexts';
6 | import drawPaths from './drawPaths';
7 |
8 | export default function (container, flattenData, shape, dataAccessor) {
9 | const voronoiData = generateVoronoiData.call(this, flattenData, shape);
10 |
11 | // Draw DOM elements for each voronoi cell
12 | container
13 | .selectAll('g')
14 | .data(voronoiData)
15 | .join(enterVoronoiCell.bind(this))
16 | .call(styleCircles.bind(this))
17 | .call(styleRects.bind(this), dataAccessor)
18 | .call(styleTexts.bind(this), dataAccessor)
19 | .call(drawPaths.bind(this));
20 | }
21 |
--------------------------------------------------------------------------------
/src/util/tableToExcel.js:
--------------------------------------------------------------------------------
1 | import XLSX from 'xlsx/dist/xlsx.mini.min';
2 | import downloadFile from './downloadFile';
3 |
4 | export default (table) => {
5 | const td = table.querySelectorAll('td');
6 | const caption = table.parentNode.querySelector('h3').innerHTML || 'title';
7 |
8 | // Replace commas (Norwegian decimal delimiter) with periods in data cells
9 | td.forEach((d) => {
10 | d.setAttribute('data-pretty', d.innerHTML);
11 | d.innerHTML = d.innerHTML.replace(',', '.').replace(' ', '');
12 | });
13 |
14 | const wb = XLSX.utils.table_to_book(table);
15 | const wbout = XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
16 | downloadFile(new Blob([wbout]), caption, '.xlsx');
17 |
18 | // Replace periods back to commas
19 | td.forEach((d) => {
20 | d.innerHTML = d.getAttribute('data-pretty');
21 | });
22 | };
23 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/rowHelpers/styleValueText.js:
--------------------------------------------------------------------------------
1 | import d3 from '@/assets/d3';
2 | import { color } from '../../colors';
3 |
4 | export default function (selection) {
5 | selection
6 | .classed('valueText', true)
7 | .attr('fill', color.purple)
8 | .attr('y', this.rowHeight / 2 + 4)
9 | .attr('x', (d, i) => this.x[i](0))
10 | .text((d) => (d[this.method] ? this.format(d[this.method], this.method) : 'Ikke tilgjengelig'))
11 | .attr('opacity', (d, i, j) => {
12 | if (this.isMobileView && this.method === 'value') return 0;
13 | const parent = d3.select(j[i].parentNode);
14 | const avgOrTotal = JSON.parse(parent.attr('data-avgRow')) || JSON.parse(parent.attr('data-totalRow'));
15 | return avgOrTotal && this.method === 'value' && d.value > this.x[i].domain()[1] ? 1 : 0;
16 | });
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/VCategory.spec.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`VCategory renders correctly 1`] = `
4 |
10 |
11 |
16 |
21 |
27 |
28 |
32 | test
33 |
34 |
37 | test
38 |
39 |
40 | `;
41 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/drawVoronoi/drawPaths.js:
--------------------------------------------------------------------------------
1 | import d3 from '@/assets/d3';
2 |
3 | export default function (selection) {
4 | const path = selection
5 | .select('path')
6 | .attr('d', (d) => `M${d.join('L')}Z`)
7 | .attr('fill-opacity', 0);
8 |
9 | // Handle mouse interactions
10 | path.on('mouseenter', ({ currentTarget }) => {
11 | const parent = d3.select(currentTarget.parentNode);
12 | parent.select('rect').attr('opacity', 1);
13 | parent.select('text').attr('opacity', 1);
14 | parent.select('circle').attr('opacity', 1);
15 | });
16 | path.on('mouseleave', ({ currentTarget }) => {
17 | const parent = d3.select(currentTarget.parentNode);
18 | parent.select('rect').attr('opacity', 0);
19 | parent.select('text').attr('opacity', 0);
20 | parent.select('circle').attr('opacity', 0);
21 | });
22 | }
23 |
--------------------------------------------------------------------------------
/src/util/tooltip.js:
--------------------------------------------------------------------------------
1 | import { select } from 'd3';
2 |
3 | const body = select('body');
4 |
5 | // Creates DOM elements for generic tooltip
6 | export const showTooltipOver = (str, delay = 0) => {
7 | const tooltip = body
8 | .append('div')
9 | .attr('class', 'tooltip')
10 | .attr('aria-hidden', true)
11 | .html(str)
12 | .style('top', '-30px')
13 | .style('left', function () {
14 | const div = select(this).node().getBoundingClientRect().width;
15 | return `${div / -2}px`;
16 | });
17 |
18 | setTimeout(() => {
19 | tooltip.classed('showTooltip', true);
20 | }, delay);
21 | };
22 |
23 | export const showTooltipMove = (e) => {
24 | if (!e) return;
25 | body.select('div.tooltip').style('transform', `translate(${e.pageX}px, ${e.pageY}px)`);
26 | };
27 |
28 | export const hideTooltip = () => {
29 | body.select('div.tooltip').remove();
30 | };
31 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md:
--------------------------------------------------------------------------------
1 | Fixes #?
2 |
3 | Change/Fix/Added:
4 |
5 | ### Universal design checklist __(Submitter)__
6 | - [ ] Semantic correct use of h1, h2, h3 etc? Start with h1,h2,h3 instead of h6,h7,h8.
7 | - [ ] All input, links, images, buttons and visible elements should have alt text.
8 | - [ ] input fields must have placeholder and label.
9 |
10 | ### Merge criteria for dev __(Submitter)__
11 | - [ ] Go through *universal design* checklist
12 | - [ ] Does the new feature support mobile devices? (Responsive website)
13 | - [ ] Update Trello board
14 | - [ ] Merge into master (Merge button)
15 | - [ ] Delete branch
16 |
17 | ### Review criteria for merge __(Reviewer)__
18 | - [ ] Have PR-author gone through *universal design* checklist?
19 | - [ ] Does it support mobile, tablet and desktop?
20 | - [ ] Code review
21 | - [ ] npm run test
22 | - [ ] Approve PR
23 |
--------------------------------------------------------------------------------
/src/config/predefinedOptions.js:
--------------------------------------------------------------------------------
1 | export default [
2 | { option: [], label: '- Velg byområde -', selected: true, disabled: true },
3 | { option: ['04', '05', '06', '07', '08'], label: 'Vest' },
4 | { option: ['01', '02', '03', '09', '10', '11', '12', '13', '14', '15'], label: 'Øst' },
5 | { option: ['01', '02', '03', '04', '05'], label: 'Indre by' },
6 | { option: ['01', '02', '03'], label: 'Indre by øst' },
7 | { option: ['04', '05'], label: 'Indre by vest' },
8 | { option: ['06', '07', '08', '09', '10', '11', '12', '13', '14', '15'], label: 'Ytre by' },
9 | { option: ['06', '07', '08'], label: 'Ytre by vest' },
10 | { option: ['09', '10', '11', '12'], label: 'Ytre by øst' },
11 | { option: ['13', '14', '15'], label: 'Ytre by sør' },
12 | {
13 | option: ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15'],
14 | label: 'Velg alle',
15 | },
16 | ];
17 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/columnHelpers/updateColFill.js:
--------------------------------------------------------------------------------
1 | export default function (selection) {
2 | selection
3 | .select('rect.colFill')
4 | .attr('y', -10)
5 | .transition()
6 | .duration(this.duration)
7 | .attr('height', this.height + 20)
8 | .duration(this.duration)
9 | .attr('width', (d, i) => {
10 | let val;
11 | const totalRow = this.filteredData.data.find((dj) => dj.totalRow);
12 |
13 | if (totalRow && totalRow.values && totalRow.values[i]) {
14 | val = totalRow.values[i][this.method];
15 | } else {
16 | return 0;
17 | }
18 |
19 | if ((this.method === 'value' && val > this.x[i].domain()[1]) || this.isMobileView) {
20 | return 0;
21 | }
22 | if (this.filteredData.data.filter((dj) => dj.totalRow).length) {
23 | return this.x[0](val);
24 | }
25 | return 0;
26 | });
27 | }
28 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/boxplotHelpers/index.js:
--------------------------------------------------------------------------------
1 | import positionLegend from './positionLegend';
2 | import createBoxPlotLegend from './createBoxPlotLegend';
3 | import createRowElements from './createRowElements';
4 | import { updateRowBox } from './rowBox';
5 | import { updateRowGeography } from './rowGeography';
6 | import { updateRowMedianText } from './rowMedianText';
7 | import { updateRowMeanText } from './rowMeanText';
8 | import { updateRowMeanRect } from './rowMeanRect';
9 | import { updateRowMedianRect } from './rowMedianRect';
10 | import { updateRowFill } from './rowFill';
11 | import { updateRowDivider } from './rowDivider';
12 |
13 | export {
14 | positionLegend,
15 | createBoxPlotLegend,
16 | createRowElements,
17 | updateRowBox,
18 | updateRowGeography,
19 | updateRowMedianText,
20 | updateRowMeanText,
21 | updateRowMeanRect,
22 | updateRowMedianRect,
23 | updateRowFill,
24 | updateRowDivider,
25 | };
26 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/sortData.js:
--------------------------------------------------------------------------------
1 | export default function (data = [], options = {}) {
2 | if (typeof options.series === 'number') {
3 | const direction = options.direction ? options.direction : 'desc';
4 |
5 | const idx = options.series;
6 | const mtd = this.method;
7 |
8 | data.data.sort((a, b) => {
9 | if (!a.values) {
10 | throw new Error('Invalid data structure');
11 | }
12 |
13 | // Sort avgRow and totalRow
14 | if (b.totalRow && a.avgRow) return -1;
15 | if (a.totalRow && b.avgRow) return 1;
16 | if (a.totalRow || a.avgRow) return 1;
17 | if (b.totalRow || b.avgRow) return -1;
18 | if (a.avgRow || a.totalRow) return 1;
19 |
20 | // Sort the rest of the geographies
21 | if (direction === 'asc') {
22 | return a.values[idx][mtd] - b.values[idx][mtd];
23 | }
24 | return b.values[idx][mtd] - a.values[idx][mtd];
25 | });
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/config/geoData/districts.js:
--------------------------------------------------------------------------------
1 | import alna from './alna';
2 | import bjerke from './bjerke';
3 | import frogner from './frogner';
4 | import gamleoslo from './gamle_oslo';
5 | import grorud from './grorud';
6 | import grunerlokka from './grunerlokka';
7 | import nordreaker from './nordre_aker';
8 | import nordstrand from './nordstrand';
9 | import oslo from './oslo';
10 | import ostensjo from './ostensjo';
11 | import sagene from './sagene';
12 | import sondrenordstrand from './sondre_nordstrand';
13 | import sthanshaugen from './st_hanshaugen';
14 | import stovner from './stovner';
15 | import ullern from './ullern';
16 | import vestreaker from './vestre_aker';
17 |
18 | export default {
19 | alna,
20 | bjerke,
21 | frogner,
22 | gamleoslo,
23 | grorud,
24 | grunerlokka,
25 | nordreaker,
26 | nordstrand,
27 | oslo,
28 | ostensjo,
29 | sagene,
30 | sondrenordstrand,
31 | sthanshaugen,
32 | stovner,
33 | ullern,
34 | vestreaker,
35 | };
36 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/rowHelpers/updateBars.js:
--------------------------------------------------------------------------------
1 | export default function (selection) {
2 | selection
3 | .attr('height', (d, i, j) => {
4 | if (this.isMobileView) return 7;
5 | return j[0].parentNode.__data__.totalRow ? 2 : this.barHeight;
6 | })
7 | .attr('y', (d, i, j) => {
8 | if (this.isMobileView) {
9 | return (this.rowHeight - this.barHeight) / 2 + 12;
10 | }
11 | return j[0].parentNode.__data__.totalRow ? this.rowHeight / 2 : (this.rowHeight - this.barHeight) / 2;
12 | })
13 | .attr('opacity', (d, i) =>
14 | i === this.highlight || this.highlight === -1 || this.highlight === undefined ? 1 : 0.2
15 | )
16 | .transition()
17 | .duration(this.duration)
18 | .attr('width', (d, i) => {
19 | if (this.method === 'value' && d[this.method] > this.x[i].domain()[1]) return 0;
20 | return Math.max(this.x[0](d[this.method]), 0);
21 | })
22 | .attr('x', (d, i) => this.x[i](0));
23 | }
24 |
--------------------------------------------------------------------------------
/server/auth/api.js:
--------------------------------------------------------------------------------
1 | const axios = require('axios');
2 | const querystring = require('querystring');
3 |
4 | const envs = {
5 | access_token_url: `${process.env.KEYCLOAK_SERVER_URL}/auth/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
6 | };
7 |
8 | const request = (params) => {
9 | return axios({
10 | method: 'post',
11 | url: envs.access_token_url,
12 | data: querystring.stringify(params),
13 | headers: {
14 | 'Content-Type': 'application/x-www-form-urlencoded',
15 | },
16 | })
17 | .then((response) => {
18 | return Promise.resolve(response.data);
19 | })
20 | .catch((error) => {
21 | if (error.errno === 'ETIMEDOUT') {
22 | return Promise.reject(new Error('Timeout'));
23 | }
24 | if (error.response === undefined) {
25 | return Promise.reject(new Error('Keycloak is most likely down'));
26 | }
27 | return Promise.reject(error.response);
28 | });
29 | };
30 |
31 | module.exports.request = request;
32 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: "[BUG]"
5 | labels: bug
6 | assignees: aulonm
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/src/views/__tests__/notFound.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils';
2 | import { createStore } from 'vuex';
3 | import { createRouter, createWebHistory } from 'vue-router';
4 | import i18n from '@/i18n';
5 | import { routes } from '@/router';
6 | import mockStore from '@/../tests/MockStore';
7 | import NotFound from '../NotFound.vue';
8 |
9 | describe('NotFound', () => {
10 | let wrapper = null;
11 | let router = null;
12 | let store = null;
13 |
14 | beforeEach(() => {
15 | store = createStore(mockStore);
16 | router = createRouter({
17 | history: createWebHistory(),
18 | routes,
19 | });
20 |
21 | wrapper = mount(NotFound, {
22 | global: {
23 | plugins: [router, store, i18n],
24 | stubs: {
25 | 'v-category': true,
26 | },
27 | },
28 | });
29 | });
30 |
31 | afterEach(() => {
32 | wrapper.unmount();
33 | });
34 |
35 | test('renders component correctly', () => {
36 | expect(wrapper.element).toMatchSnapshot();
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/server/auth/accessToken.js:
--------------------------------------------------------------------------------
1 | const isDate = require('date-fns/isDate');
2 | const addSeconds = require('date-fns/addSeconds');
3 | const subSeconds = require('date-fns/subSeconds');
4 | const isAfter = require('date-fns/isAfter');
5 |
6 | const addExpirationInformation = (token) => {
7 | const parsedTokenProps = {};
8 |
9 | if ('expires_in' in token) {
10 | if (!isDate(token.expires_in)) {
11 | parsedTokenProps.expires_at = addSeconds(new Date(), Number.parseInt(token.expires_in, 10));
12 | }
13 | }
14 |
15 | if ('refresh_expires_in' in token) {
16 | if (!isDate(token.refresh_expires_in)) {
17 | parsedTokenProps.refresh_expires_at = addSeconds(new Date(), Number.parseInt(token.refresh_expires_in, 10));
18 | }
19 | }
20 |
21 | return { ...token, ...parsedTokenProps };
22 | };
23 |
24 | const shouldRefreshToken = (expiresAt) => {
25 | return isAfter(new Date(), subSeconds(expiresAt, 10));
26 | };
27 |
28 | module.exports.shouldRefreshToken = shouldRefreshToken;
29 | module.exports.addExpirationInformation = addExpirationInformation;
30 |
--------------------------------------------------------------------------------
/src/components/__tests__/VCategory.spec.js:
--------------------------------------------------------------------------------
1 | import { mount, RouterLinkStub } from '@vue/test-utils';
2 | import store from '@/store';
3 | import i18n from '@/i18n';
4 | import VCategory from '../VCategory.vue';
5 |
6 | describe('VCategory', () => {
7 | let wrapper = null;
8 |
9 | beforeEach(() => {
10 | wrapper = mount(VCategory, {
11 | global: {
12 | plugins: [i18n, store],
13 | stubs: { RouterLink: RouterLinkStub },
14 | },
15 | props: {
16 | category: 'test',
17 | topic: 'test',
18 | bgImage: 'test',
19 | bgColor: 'black',
20 | txtColor: 'white',
21 | id: 'test',
22 | link: '/test',
23 | district: 'sagene',
24 | },
25 | });
26 | });
27 |
28 | afterEach(() => {
29 | wrapper.unmount();
30 | });
31 |
32 | test('renders VCategory-component and finds main-container__item-class', () => {
33 | expect(wrapper.classes('main-container__item')).toBe(true);
34 | });
35 |
36 | test('renders correctly', () => {
37 | expect(wrapper.element).toMatchSnapshot();
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/punkt.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "svgsprite": {
3 | "files": [
4 | "./node_modules/@oslokommune/punkt-assets/dist/icons/chevron-down.svg",
5 | "./node_modules/@oslokommune/punkt-assets/dist/icons/close.svg",
6 | "./node_modules/@oslokommune/punkt-assets/dist/icons/download.svg",
7 | "./node_modules/@oslokommune/punkt-assets/dist/icons/expand.svg",
8 | "./node_modules/@oslokommune/punkt-assets/dist/icons/graph.svg",
9 | "./node_modules/@oslokommune/punkt-assets/dist/icons/location-pin.svg",
10 | "./node_modules/@oslokommune/punkt-assets/dist/icons/menu.svg",
11 | "./node_modules/@oslokommune/punkt-assets/dist/icons/minimize.svg",
12 | "./node_modules/@oslokommune/punkt-assets/dist/icons/new-window-small.svg",
13 | "./node_modules/@oslokommune/punkt-assets/dist/icons/picture.svg",
14 | "./node_modules/@oslokommune/punkt-assets/dist/icons/question.svg",
15 | "./node_modules/@oslokommune/punkt-assets/dist/icons/table.svg"
16 | ],
17 | "output": {
18 | "fileType": "html",
19 | "filePath": "src/components/PktIconsSprite.vue"
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/__tests__/graphInstance.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils';
2 | import Vue3Resize from 'vue3-resize';
3 | import { topics } from '@/config/topics';
4 | import store from '@/store';
5 | import i18n from '@/i18n';
6 | import GraphInstance from '../GraphInstance.vue';
7 |
8 | describe('GraphInstance', () => {
9 | let wrapper = null;
10 |
11 | beforeEach(() => {
12 | GraphInstance.methods.draw = jest.fn();
13 |
14 | wrapper = mount(GraphInstance, {
15 | global: {
16 | plugins: [store, i18n, Vue3Resize],
17 | stubs: {
18 | spinner: true,
19 | },
20 | },
21 | props: {
22 | settings: topics.alder.cards[0],
23 | showTable: false,
24 | mode: 'graph',
25 | },
26 | });
27 | });
28 |
29 | afterEach(() => {
30 | wrapper.unmount();
31 | });
32 |
33 | test('renders graphInstance-component and finds graph__container-class', () => {
34 | expect(wrapper.classes('graph__shadow')).toBe(true);
35 | });
36 |
37 | test('renders correctly', () => {
38 | expect(wrapper.element).toMatchSnapshot();
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Oslo kommune
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/columnHelpers/createColumns.js:
--------------------------------------------------------------------------------
1 | import { color } from '../../colors';
2 |
3 | export default function (selection) {
4 | const g = selection.append('g').attr('class', 'column');
5 | g.append('rect').call(styleColumnBackground);
6 | g.append('rect').call(styleClickTrigger);
7 | g.append('rect').call(styleArrow);
8 | g.append('text').call(styleColumnHeading);
9 | g.append('text').call(styleColumnSubheading);
10 | return g;
11 | }
12 |
13 | function styleColumnBackground(el) {
14 | el.attr('fill', color.light_grey).attr('class', 'colFill');
15 | }
16 |
17 | function styleClickTrigger(el) {
18 | el.attr('fill', color.light_grey).attr('class', 'clickTrigger');
19 | }
20 |
21 | function styleArrow(el) {
22 | el.attr('class', 'arrow').attr('width', 1).attr('height', 11);
23 | }
24 |
25 | function styleColumnHeading(el) {
26 | el.attr('class', 'colHeading').attr('transform', 'translate(0, -40)').style('pointer-events', 'none');
27 | }
28 |
29 | function styleColumnSubheading(el) {
30 | el.attr('class', 'colSubheading').attr('transform', 'translate(0, -20)').style('pointer-events', 'none');
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/graphInstance.spec.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`GraphInstance renders correctly 1`] = `
4 |
9 |
13 |
16 |
17 |
20 | Henter data
21 |
22 |
23 |
24 |
28 |
29 |
43 |
47 |
53 |
54 |
55 |
56 | `;
57 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/rowHelpers/styleRowName.js:
--------------------------------------------------------------------------------
1 | import util from '@/util/graph-templates/template-utils';
2 | import getRowName from './getRowName';
3 |
4 | export default function (selection) {
5 | selection
6 | .text(getRowName.bind(this))
7 | .attr('x', () => {
8 | if (this.isMobileView) return 0;
9 | return this.data.meta.series.length > 1 ? -this.padding.left + 10 : -10;
10 | })
11 | .attr('y', () => (this.isMobileView ? 15 : this.rowHeight / 2 + 4))
12 | .attr('text-anchor', () => {
13 | if (this.isMobileView) return 'start';
14 | if (this.data.meta.series.length > 1) return 'start';
15 | return 'end';
16 | })
17 | .style('cursor', (d) => {
18 | if (d.noLink) return false;
19 | return (this.isCompare && !d.totalRow) || (!this.isCompare && d.totalRow) ? 'pointer' : false;
20 | })
21 | .style('text-decoration', (d) => {
22 | if (d.noLink) return null;
23 | const isDistrict = util.allDistricts.some((district) => district.value === d.geography);
24 |
25 | return (this.isCompare && !d.totalRow) || (!this.isCompare && d.totalRow) || isDistrict ? 'underline' : false;
26 | });
27 | }
28 |
--------------------------------------------------------------------------------
/src/util/graph-templates/locale.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Norwegian date and number formatting.
3 | */
4 |
5 | import { formatLocale } from 'd3';
6 |
7 | const timeFormat = {
8 | dateTime: '%A %e %B %Y %H:%M:%S',
9 | date: '%d.%m.%Y',
10 | time: '%H:%M:%S',
11 | periods: ['AM', 'PM'],
12 | days: ['Søndag', 'Mandag', 'Tirsdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lørdag'],
13 | shortDays: ['Søn', 'Man', 'Tir', 'Ons', 'Tor', 'Fre', 'Lør'],
14 | months: [
15 | 'Januar',
16 | 'Februar',
17 | 'Mars',
18 | 'April',
19 | 'Mai',
20 | 'Juni',
21 | 'Juli',
22 | 'August',
23 | 'September',
24 | 'Oktober',
25 | 'November',
26 | 'Desember',
27 | ],
28 | shortMonths: ['Jan', 'Feb', 'Mar', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Des'],
29 | };
30 |
31 | const norwegianLocale = formatLocale({
32 | decimal: ',',
33 | thousands: ' ',
34 | grouping: [3],
35 | currency: ['', ' kr'],
36 | percent: '%',
37 | });
38 |
39 | const tableLocale = formatLocale({
40 | decimal: ',',
41 | thousands: ' ',
42 | grouping: [3],
43 | currency: ['', ' kr'],
44 | percent: ' ',
45 | });
46 |
47 | export { timeFormat, norwegianLocale, tableLocale };
48 |
--------------------------------------------------------------------------------
/src/components/__tests__/navigationDrawer.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils';
2 | import VueSkipTo from '@vue-a11y/skip-to';
3 | import router from '@/router';
4 | import store from '@/store';
5 | import i18n from '@/i18n';
6 | import TheNavigationDrawer from '../TheNavigationDrawer.vue';
7 |
8 | global.scroll = jest.fn();
9 | window.scroll = jest.fn();
10 |
11 | describe('TheNavigationDrawer', () => {
12 | let wrapper = null;
13 |
14 | beforeEach(() => {
15 | wrapper = mount(TheNavigationDrawer, {
16 | global: {
17 | plugins: [router, store, i18n, VueSkipTo],
18 | stubs: {
19 | 'oslo-logo': true,
20 | },
21 | },
22 | });
23 | });
24 |
25 | afterEach(() => {
26 | wrapper.unmount();
27 | });
28 |
29 | test('renders TheNavigationDrawer-component and finds navbar-id', () => {
30 | expect(wrapper.classes('navbar')).toBe(true);
31 | });
32 |
33 | test('renders TheNavigationDrawer correctly', () => {
34 | expect(wrapper.element).toMatchSnapshot();
35 | });
36 |
37 | test('render TheNavigationDrawer with route /bydel/sagene correctly', async () => {
38 | await router.push('/bydel/sagene');
39 | expect(wrapper.element).toMatchSnapshot();
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/src/util/positionLabels.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {array} data - data array from template
3 | * @param {number} height - Minimum height between each element
4 | * @returns {array} - Data array with newly created 'start' and 'end'
5 | * attributes for each of the elements of the array
6 | * @description Calculates the ideal non-colliding y-position of labels
7 | * based on their initial positions.
8 | */
9 |
10 | function avoidCollisions(data, height) {
11 | const spacing = 15;
12 | const walk = 2;
13 |
14 | data = data
15 | .map((d) => {
16 | d.start = d.y;
17 | d.end = d.y + spacing;
18 | return d;
19 | })
20 | .sort((a, b) => a.start - b.start);
21 |
22 | let collision = true;
23 | while (collision) {
24 | collision = false;
25 | for (let i = 0; i < data.length - 1; i += 1) {
26 | if (data[i].end > data[i + 1].start) {
27 | if (data[i].start > 0) {
28 | data[i].start -= walk;
29 | data[i].end -= walk;
30 | }
31 | if (data[i + 1].end < height) {
32 | data[i + 1].start += walk;
33 | data[i + 1].end += walk;
34 | }
35 | collision = true;
36 | }
37 | }
38 | }
39 | return data;
40 | }
41 |
42 | export default avoidCollisions;
43 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/generateTableData.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-nested-ternary */
2 | // TODO: refactor this function to be more flexible and reusable for all templates
3 |
4 | export default function generateTableData() {
5 | const isMultiLevel = this.data.meta.series[1] !== undefined;
6 | let tableHead;
7 | if (isMultiLevel) {
8 | tableHead = [
9 | ['Geografi', this.method === 'value' ? 'Antall' : this.showPermille ? 'Promilleandel' : 'Prosentandel'],
10 | [
11 | ...this.data.meta.series.map((d) => {
12 | let str = '';
13 | if (typeof d === 'string') {
14 | str += d;
15 | } else if (d.heading) {
16 | str += `${d.heading} ${d.subheading}`;
17 | }
18 | return str;
19 | }),
20 | ],
21 | ];
22 | } else {
23 | tableHead = ['Geografi', this.method === 'value' ? 'Antall' : this.showPermille ? 'Promilleandel' : 'Prosentandel'];
24 | }
25 | const tableBody = JSON.parse(JSON.stringify(this.data.data))
26 | .sort(this.tableSort)
27 | .map((row) => {
28 | return {
29 | key: row.geography,
30 | values: row.values.map((d) => d[this.method]),
31 | };
32 | });
33 |
34 | return [tableHead, tableBody];
35 | }
36 |
--------------------------------------------------------------------------------
/src/views/__tests__/district.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils';
2 | import { createStore } from 'vuex';
3 | import { createRouter, createWebHistory } from 'vue-router';
4 | import i18n from '@/i18n';
5 | import { routes } from '@/router';
6 | import mockStore from '@/../tests/MockStore';
7 | import District from '../District.vue';
8 |
9 | describe('Bydel', () => {
10 | let wrapper = null;
11 | let router = null;
12 | let store = null;
13 |
14 | beforeEach(() => {
15 | store = createStore(mockStore);
16 | router = createRouter({
17 | history: createWebHistory(),
18 | routes,
19 | });
20 |
21 | wrapper = mount(District, {
22 | global: {
23 | plugins: [router, store, i18n],
24 | stubs: {
25 | VLeaflet: true,
26 | UxSignals: true,
27 | },
28 | },
29 | props: {
30 | district: 'gamleoslo',
31 | },
32 | });
33 | });
34 |
35 | afterEach(() => {
36 | wrapper.unmount();
37 | });
38 |
39 | test('renders bydel-component and finds main-container class', () => {
40 | expect(wrapper.classes('main-container')).toBe(true);
41 | });
42 |
43 | test('renders component correctly', () => {
44 | expect(wrapper.element).toMatchSnapshot();
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue';
2 | import { VueHeadMixin, createHead } from '@unhead/vue';
3 | import VueGtag from 'vue-gtag';
4 | import Vue3Resize from 'vue3-resize';
5 | import VueSkipTo from '@vue-a11y/skip-to';
6 | import '@vue-a11y/skip-to/dist/style.css';
7 | import './util/polyfills';
8 | import App from './App.vue';
9 | import router from './router';
10 | import store from './store';
11 | import i18n from './i18n';
12 | import clickOutside from './directives/clickOutside';
13 |
14 | import 'vue3-resize/dist/vue3-resize.css';
15 | import 'leaflet/dist/leaflet.css';
16 |
17 | import './styles/main.scss';
18 |
19 | const production = import.meta.env.PROD;
20 | const envs = production ? JSON.parse(window.__GLOBAL_ENVS__) : {};
21 |
22 | const app = createApp(App);
23 |
24 | const head = createHead();
25 | app.mixin(VueHeadMixin);
26 | app.use(head);
27 |
28 | app.use(store);
29 | app.use(router);
30 | app.use(i18n);
31 |
32 | app.use(Vue3Resize);
33 | app.use(VueSkipTo);
34 |
35 | // Directive to detect clicks outside of an element
36 | app.directive('click-outside', clickOutside);
37 |
38 | app.use(VueGtag, {
39 | config: {
40 | id: production ? envs.VITE_GOOGLE_ANALYTICS_ID : import.meta.env.VITE_GOOGLE_ANALYTICS_ID,
41 | },
42 | router,
43 | });
44 |
45 | app.mount('#app');
46 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/closeButton/init.js:
--------------------------------------------------------------------------------
1 | import d3 from '@/assets/d3';
2 | import { color } from '../../colors';
3 |
4 | export default function init() {
5 | const { duration } = this;
6 |
7 | const g = this.canvas
8 | .append('g')
9 | .attr('class', 'close')
10 | .style('display', 'none')
11 | .on('click keyup', (e) => {
12 | if (e && e.type === 'keyup' && e.key !== 'Enter') return;
13 | this.render(this.data, { method: this.method });
14 | });
15 |
16 | // Close button background
17 | g.append('rect')
18 | .attr('width', 30)
19 | .attr('height', 30)
20 | .attr('fill', color.red)
21 | .style('cursor', 'pointer')
22 | .attr('opacity', 0.7)
23 | .on('mouseenter', function () {
24 | d3.select(this).transition().duration(duration).attr('opacity', 1);
25 | })
26 | .on('mouseleave', function () {
27 | d3.select(this).transition().duration(duration).attr('opacity', 0.7);
28 | });
29 |
30 | // Close button icon
31 | g.append('text')
32 | .attr('fill', color.purple)
33 | .style('pointer-events', 'none')
34 | .text('x')
35 | .attr('font-weight', 700)
36 | .attr('font-size', 20)
37 | .attr('text-anchor', 'middle')
38 | .attr('transform', 'translate(15, 20)');
39 |
40 | return g;
41 | }
42 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/columnHelpers/updateColArrow.js:
--------------------------------------------------------------------------------
1 | export default function (selection) {
2 | selection
3 | .select('rect.arrow')
4 | .attr('transform', `translate(0, ${this.rowHeight / 2 - 5})`)
5 | .transition()
6 | .duration(this.duration)
7 | .attr('y', () => this.filteredData.data.findIndex((d) => d.totalRow) * this.rowHeight)
8 | .attr('x', (d, i) => {
9 | let val;
10 | const totalRow = this.filteredData.data.find((dj) => dj.totalRow);
11 |
12 | if (totalRow && totalRow.values && totalRow.values[i]) {
13 | val = totalRow.values[i][this.method];
14 | } else {
15 | return null;
16 | }
17 |
18 | if (this.method === 'value' && val > this.x[i].domain()[1]) return 0;
19 | return this.x[0](val);
20 | })
21 | .attr('opacity', (d, i) => {
22 | let val;
23 | const totalRow = this.filteredData.data.find((dj) => dj.totalRow);
24 |
25 | if (totalRow && totalRow.values && totalRow.values[i]) {
26 | val = totalRow.values[i][this.method];
27 | } else {
28 | return 0;
29 | }
30 |
31 | if (this.isMobileView) {
32 | return 0;
33 | }
34 | if (this.method === 'value' && val > this.x[i].domain()[1]) {
35 | return 0;
36 | }
37 | return 1;
38 | });
39 | }
40 |
--------------------------------------------------------------------------------
/src/util/downloadSvg.js:
--------------------------------------------------------------------------------
1 | import { select } from 'd3';
2 | import downloadFile from './downloadFile';
3 |
4 | // Generates the svg blob and calls download function
5 | export default function (svgData, filename) {
6 | svgData = cleanSvgData(svgData);
7 |
8 | const preface = '\r\n';
9 | const svgBlob = new Blob([preface, svgData], { type: 'image/svg+xml;charset=utf-8' });
10 |
11 | downloadFile(svgBlob, filename, '.svg');
12 | }
13 |
14 | // Parses the svgData str and strips away
15 | // mouse interactions using d3 and returns
16 | // a cleaned up svgData string.
17 | function cleanSvgData(str) {
18 | const parser = new DOMParser();
19 | const doc = parser.parseFromString(str, 'image/svg+xml');
20 | const svg = select(doc).select('svg');
21 |
22 | svg.selectAll('*').attr('tabindex', null).style('cursor', null);
23 |
24 | svg.select('.close').remove();
25 |
26 | // Remove hyperlinks by grabbing their children
27 | // and appending them to their grandparent before
28 | // removing the -elements.
29 | const hyperlinkChildren = svg.selectAll('a > *');
30 | hyperlinkChildren.each(function () {
31 | const parent = select(this).node().parentElement.parentElement;
32 | parent.append(select(this).node());
33 | });
34 | svg.selectAll('a').remove();
35 |
36 | return svg.node().outerHTML;
37 | }
38 |
--------------------------------------------------------------------------------
/.github/workflows/pull_request.yml:
--------------------------------------------------------------------------------
1 | name: Test pull request
2 |
3 | on:
4 | pull_request
5 |
6 | jobs:
7 | test-PR:
8 |
9 | runs-on: ubuntu-latest
10 |
11 | strategy:
12 | matrix:
13 | node-version: [20.x]
14 |
15 | steps:
16 | - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
17 |
18 | - name: Cache
19 | uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
20 | with:
21 | path: |
22 | ~/cache
23 | !~/cache/exclude
24 | **/node_modules
25 | ~/.cache
26 | key: ${{ runner.os }}-${{ hashFiles('package-lock.json') }}
27 | restore-keys: |
28 | ${{ runner.os }}-${{ hashFiles('package-lock.json') }}
29 |
30 | - name: Use Node.js ${{ matrix.node-version }}
31 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
32 | with:
33 | node-version: ${{ matrix.node-version }}
34 | - name: npm install, test, and build
35 | run: |
36 | npm install
37 | npm install --prefix server
38 | env:
39 | CI: true
40 | - name: npm lint
41 | run: |
42 | npm run lint
43 | npm run lint:style
44 | env:
45 | CI: true
46 | - name: npm test
47 | run: |
48 | npm run test:unit
49 | env:
50 | CI: true
51 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Bydelsfakta
2 |
3 | 👉 **https://bydelsfakta.oslo.kommune.no/**
4 |
5 | Bydelsfakta is a frontend application for Oslo municipality which visualises demographic statistics and data on various topics for the 15 districts in the City of Oslo.
6 |
7 | #### Install dependencies
8 | Install dependencies in both the root folder and the server folder for the backend
9 | ```
10 | npm install
11 | cd server && npm install
12 | ```
13 |
14 | #### Run it locally for local development
15 | In order to run this application, two different services is needed: one for frontend and one for backend
16 |
17 | ##### Frontend
18 | ```
19 | npm run serve
20 | ```
21 |
22 | ##### Backend
23 | Need to build the frontend once before running the backend
24 | ```
25 | npm run backend
26 | ```
27 |
28 | The backend needs some local variables, these are mostly secret, so if you need them then send us a DM and we could give you access.
29 |
30 | #### Build the project
31 | ```
32 | npm run build
33 | ```
34 |
35 | #### Run jest tests
36 | ```
37 | npm run test:unit:watch
38 | ```
39 |
40 | #### Lints and fixes files
41 | ```
42 | npm run lint
43 | ```
44 |
45 | #### Stylelint
46 | ```
47 | npm run lint:style:fix
48 | ```
49 |
50 | #### Github Actions
51 |
52 | We now use github actions to build the bydelsfakta-frontend, so you can check out when the last successfull build was built in the actions-tab.
53 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/boxplotHelpers/createRowElements.js:
--------------------------------------------------------------------------------
1 | import { color } from '@/util/graph-templates/colors';
2 | import { initRowBox } from './rowBox';
3 | import { initRowGeography } from './rowGeography';
4 | import { initRowMedianText } from './rowMedianText';
5 | import { initMeanText } from './rowMeanText';
6 | import { initMeanRect } from './rowMeanRect';
7 | import { initRowMedianRect } from './rowMedianRect';
8 | import { initRowFill } from './rowFill';
9 |
10 | export default function (enter) {
11 | const g = enter
12 | .append('g')
13 | .classed('row', true)
14 | .attr('transform', (d, i) => `translate(0, ${i * this.rowHeight})`);
15 |
16 | g.append('rect').call(initRowFill.bind(this));
17 | g.append('rect').call(initRowDivider.bind(this));
18 | g.append('text').call(initRowGeography.bind(this));
19 | g.append('rect').call(initRowBox.bind(this));
20 | g.append('text').call(initRowMedianText.bind(this));
21 | g.append('rect').call(initRowMedianRect.bind(this));
22 | g.append('text').call(initMeanText.bind(this));
23 | g.append('rect').call(initMeanRect.bind(this));
24 |
25 | return g;
26 | }
27 |
28 | function initRowDivider(selection) {
29 | selection
30 | .attr('class', 'divider')
31 | .attr('fill', color.purple)
32 | .attr('x', -this.padding.left)
33 | .attr('height', 1)
34 | .attr('y', this.rowHeight);
35 | }
36 |
--------------------------------------------------------------------------------
/src/__tests__/app.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils';
2 | import { createStore } from 'vuex';
3 | import { createRouter, createWebHistory } from 'vue-router';
4 | import Vue3Resize from 'vue3-resize';
5 | import VueSkipTo from '@vue-a11y/skip-to';
6 | import App from '@/App.vue';
7 | import clickOutside from '@/directives/clickOutside';
8 | import i18n from '@/i18n';
9 | import mockStore from '@/../tests/MockStore';
10 | import { routes } from '@/router';
11 |
12 | global.scroll = jest.fn();
13 | window.scroll = jest.fn();
14 |
15 | describe('App', () => {
16 | let wrapper = null;
17 | let store = null;
18 | let router = null;
19 |
20 | beforeEach(() => {
21 | store = createStore(mockStore);
22 | router = createRouter({
23 | history: createWebHistory(),
24 | routes,
25 | });
26 |
27 | wrapper = mount(App, {
28 | global: {
29 | plugins: [router, store, i18n, Vue3Resize, VueSkipTo],
30 | directives: {
31 | 'click-outside': clickOutside,
32 | },
33 | },
34 | shallow: true,
35 | });
36 | });
37 |
38 | afterEach(() => {
39 | wrapper.unmount();
40 | });
41 |
42 | test('renders app-component and finds id #app', () => {
43 | expect(wrapper.classes('app')).toBe(true);
44 | });
45 |
46 | test('shallowmounts app', () => {
47 | expect(wrapper.element).toMatchSnapshot();
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/src/assets/d3/index.js:
--------------------------------------------------------------------------------
1 | // tried to tree-shake but we would only save about 100-120kb, not worth it.
2 | /* import {
3 | line,
4 | brushX,
5 | move,
6 | extent,
7 | on,
8 | timeFormat,
9 | timeParse,
10 | timeYear,
11 | max,
12 | min,
13 | sum,
14 | format,
15 | select,
16 | selectAll,
17 | event,
18 | selection,
19 | scaleLinear,
20 | scaleTime,
21 | scaleBand,
22 | scaleOrdinal,
23 | axisTop,
24 | axisBottom,
25 | axisLeft,
26 | timeFormatDefaultLocale,
27 | scaleThreshold,
28 | color,
29 | interpolateRainbow,
30 | voronoi,
31 | area,
32 | axisRight,
33 | quantile,
34 | curveBasis,
35 | curveStep,
36 | mean,
37 | mouse,
38 | Page
39 | } from 'd3'; */
40 | /*
41 |
42 | export default {
43 | mouse,
44 | curveBasis,
45 | curveStep,
46 | mean,
47 | axisRight,
48 | quantile,
49 | area,
50 | voronoi,
51 | timeYear,
52 | interpolateRainbow,
53 | color,
54 | scaleThreshold,
55 | timeFormatDefaultLocale,
56 | select,
57 | selectAll,
58 | event,
59 | selection,
60 | scaleLinear,
61 | scaleTime,
62 | scaleBand,
63 | scaleOrdinal,
64 | axisTop,
65 | axisBottom,
66 | axisLeft,
67 | format,
68 | max,
69 | min,
70 | sum,
71 | timeFormat,
72 | timeParse,
73 | line,
74 | brushX,
75 | move,
76 | extent,
77 | on,
78 | };
79 | */
80 |
81 | import * as d3 from 'd3';
82 |
83 | export default d3;
84 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/boxplotHelpers/createBoxPlotLegend.js:
--------------------------------------------------------------------------------
1 | import { boxHeight, boxWidth, textLabels } from './_legendConfig';
2 |
3 | export default function (selection) {
4 | selection.call(createBox).call(createMedianLine).call(createTextLabels);
5 | }
6 |
7 | const createBox = (selection) => {
8 | selection
9 | .attr('opacity', 0.5)
10 | .append('rect')
11 | .classed('box', true)
12 | .attr('fill', 'none')
13 | .attr('stroke', 'black')
14 | .attr('height', boxHeight)
15 | .attr('width', boxWidth)
16 | .attr('rx', 3);
17 |
18 | return selection;
19 | };
20 |
21 | const createMedianLine = (selection) => {
22 | selection
23 | .append('rect')
24 | .classed('median', true)
25 | .attr('height', boxHeight + 8)
26 | .attr('y', -4)
27 | .attr('x', boxWidth / 2 - 16)
28 | .attr('fill', 'black')
29 | .attr('width', 3);
30 | };
31 |
32 | const createTextLabels = (selection) => {
33 | selection
34 | .append('text')
35 | .text(textLabels[0])
36 | .attr('text-anchor', 'end')
37 | .attr('x', -10)
38 | .attr('y', boxHeight - 5);
39 |
40 | selection
41 | .append('text')
42 | .text(textLabels[1])
43 | .attr('x', boxWidth / 2 - 10)
44 | .attr('y', boxHeight - 5);
45 |
46 | selection
47 | .append('text')
48 | .text(textLabels[2])
49 | .attr('x', boxWidth + 10)
50 | .attr('y', boxHeight - 5);
51 | };
52 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/columnHelpers/updateClickTrigger.js:
--------------------------------------------------------------------------------
1 | export default function (selection) {
2 | selection
3 | .select('rect.clickTrigger')
4 | .style('cursor', () => (this.data.meta.series.length > 1 ? 'pointer' : 'default'))
5 | .attr('width', (d, i) => {
6 | if (this.data.meta.series.length === 1) return 0;
7 | return this.x[i].range()[1] - this.x[i].range()[0] + this.gutter;
8 | })
9 | .attr('height', this.padding.top)
10 | .attr('transform', `translate(0, -60)`)
11 | .attr('fill', 'black')
12 | .attr('opacity', 0)
13 | .on('mouseover', ({ currentTarget }) => {
14 | const i = selection.nodes().indexOf(currentTarget.parentNode);
15 | this.render(this.data, { highlight: i, selected: this.selected, method: this.method });
16 | })
17 | .on('mouseleave', () => {
18 | this.render(this.data, { highlight: -1, selected: this.selected, method: this.method });
19 | })
20 | .on('click keyup', (e) => {
21 | if (e && e.type === 'keyup' && e.key !== 'Enter') return;
22 | if (this.data.meta.series.length === 1) return;
23 | const i = selection.nodes().indexOf(e.currentTarget.parentNode);
24 | const target = this.selected > -1 ? -1 : i;
25 |
26 | this.render(this.data, { selected: target, method: this.method });
27 | })
28 | .attr('tabindex', this.filteredData.meta.series.length > 1 ? 0 : false)
29 | .append('title')
30 | .html((d) => `${d.heading} ${d.subheading}`);
31 | }
32 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/rowHelpers/styleRows.js:
--------------------------------------------------------------------------------
1 | import { color } from '@/util/graph-templates/colors';
2 | import styleRowName from './styleRowName';
3 | import styleValueText from './styleValueText';
4 |
5 | export default function (selection) {
6 | // Dynamic styling, sizing and positioning based on data and container size
7 | selection
8 | .select('rect.rowFill')
9 | .attr('fill-opacity', (d) => (d.avgRow || d.totalRow ? 0 : 0))
10 | .attr('width', this.padding.left + this.width + this.padding.right);
11 |
12 | selection
13 | .select('rect.divider')
14 | .attr('fill-opacity', (d) => {
15 | if (this.data.meta.series.length === 1) return 0;
16 | if (this.isMobileView) return 0;
17 | if (d.avgRow || d.totalRow) return 0.5;
18 | return 0.2;
19 | })
20 | .attr('width', this.padding.left + this.width + this.padding.right);
21 |
22 | selection
23 | .selectAll('text.valueText')
24 | .data((d) => d.values)
25 | .join('text')
26 | .call(styleValueText.bind(this));
27 |
28 | selection
29 | .select('text.geography')
30 | .call(styleRowName.bind(this))
31 | .attr('font-weight', (d) => (d.avgRow || d.totalRow ? 700 : 400))
32 | .append('title')
33 | .html((d) => d.geography);
34 |
35 | // Add attributes to total and avg rows
36 | selection.attr('fill', (d) => {
37 | if (d.avgRow) return color.yellow;
38 | if (d.totalRow) return color.purple;
39 | return color.purple;
40 | });
41 | }
42 |
--------------------------------------------------------------------------------
/src/config/allDistricts.js:
--------------------------------------------------------------------------------
1 | const allDistricts = [
2 | {
3 | key: '01',
4 | value: 'Bydel Gamle Oslo',
5 | uri: 'gamleoslo',
6 | },
7 | {
8 | key: '02',
9 | value: 'Bydel Grünerløkka',
10 | uri: 'grunerlokka',
11 | },
12 | {
13 | key: '03',
14 | value: 'Bydel Sagene',
15 | uri: 'sagene',
16 | },
17 | {
18 | key: '04',
19 | value: 'Bydel St. Hanshaugen',
20 | uri: 'sthanshaugen',
21 | },
22 | {
23 | key: '05',
24 | value: 'Bydel Frogner',
25 | uri: 'frogner',
26 | },
27 | {
28 | key: '06',
29 | value: 'Bydel Ullern',
30 | uri: 'ullern',
31 | },
32 | {
33 | key: '07',
34 | value: 'Bydel Vestre Aker',
35 | uri: 'vestreaker',
36 | },
37 | {
38 | key: '08',
39 | value: 'Bydel Nordre Aker',
40 | uri: 'nordreaker',
41 | },
42 | {
43 | key: '09',
44 | value: 'Bydel Bjerke',
45 | uri: 'bjerke',
46 | },
47 | {
48 | key: '10',
49 | value: 'Bydel Grorud',
50 | uri: 'grorud',
51 | },
52 | {
53 | key: '11',
54 | value: 'Bydel Stovner',
55 | uri: 'stovner',
56 | },
57 | {
58 | key: '12',
59 | value: 'Bydel Alna',
60 | uri: 'alna',
61 | },
62 | {
63 | key: '13',
64 | value: 'Bydel Østensjø',
65 | uri: 'ostensjo',
66 | },
67 | {
68 | key: '14',
69 | value: 'Bydel Nordstrand',
70 | uri: 'nordstrand',
71 | },
72 | {
73 | key: '15',
74 | value: 'Bydel Søndre Nordstrand',
75 | uri: 'sondrenordstrand',
76 | },
77 | ];
78 |
79 | export default allDistricts;
80 |
--------------------------------------------------------------------------------
/src/components/OkIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
57 |
58 |
75 |
--------------------------------------------------------------------------------
/src/styles/_print.scss:
--------------------------------------------------------------------------------
1 | @media print {
2 | @page {
3 | margin: 30px;
4 | }
5 |
6 | body {
7 | max-width: 1400px;
8 | }
9 |
10 | table {
11 | page-break-inside: avoid !important;
12 | }
13 |
14 | .navigation-topbar__select label,
15 | .navigation-topbar__select i,
16 | .card__nav,
17 | .tabs,
18 | .related {
19 | display: none !important;
20 | }
21 |
22 | .card {
23 | page-break-inside: avoid !important;
24 | box-shadow: none !important;
25 |
26 | &__header {
27 | border-bottom: none !important;
28 | }
29 | }
30 |
31 | .main-container__cards {
32 | flex-basis: 100% !important;
33 | }
34 |
35 | .graph__tablecontainer {
36 | position: relative !important;
37 | display: block !important;
38 | height: auto !important;
39 | overflow: auto !important;
40 | clip: none !important;
41 | }
42 |
43 | *,
44 | *::before,
45 | *::after,
46 | *::first-letter,
47 | p::first-line,
48 | div::first-line,
49 | blockquote::first-line,
50 | li::first-line {
51 | color: #000000 !important; /* Black prints faster: */
52 | text-shadow: none !important;
53 | background: transparent !important;
54 | box-shadow: none !important;
55 | }
56 |
57 | thead {
58 | display: table-header-group !important;
59 | }
60 |
61 | tr,
62 | img {
63 | page-break-inside: avoid !important;
64 | }
65 |
66 | p,
67 | h2,
68 | h3 {
69 | orphans: 3 !important;
70 | widows: 3 !important;
71 | }
72 |
73 | .card__title,
74 | .card,
75 | .card__header,
76 | .card__headertext {
77 | page-break-before: always !important;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/server/server.js:
--------------------------------------------------------------------------------
1 | const cors = require('cors');
2 | const express = require('express');
3 | const morgan = require('morgan');
4 | const routes = require('./routes');
5 | const compression = require('compression');
6 | const path = require('path');
7 | const cheerio = require('cheerio');
8 | const fs = require('fs');
9 |
10 | const app = express();
11 | const HOST = '0.0.0.0';
12 | const PORT = process.env.PORT || 5000;
13 |
14 |
15 | const envs = JSON.stringify({
16 | NODE_ENV: process.env.NODE_ENV,
17 | VITE_GOOGLE_ANALYTICS_ID: process.env.VITE_GOOGLE_ANALYTICS_ID,
18 | VITE_PRODUCTION_DATA: process.env.VITE_PRODUCTION_DATA,
19 | VITE_INFO_SHOW: process.env.VITE_SHOW_INFO,
20 | VITE_INFO_MESSAGE: process.env.VITE_INFO_MESSAGE,
21 | });
22 |
23 | const $ = cheerio.load(fs.readFileSync(path.join(__dirname, '../docs/index.html')));
24 | $('body').find('#bydelsfakta-globals').remove();
25 | $('')
28 | .prependTo('body');
29 |
30 | fs.writeFileSync(path.join(__dirname, '../docs/index.html'), $.html(), { encoding: 'utf8', flag: 'w' });
31 |
32 |
33 | app.use(morgan('combined'));
34 | app.use(cors());
35 | app.use(compression());
36 |
37 | app.use(function(req, res, next) {
38 | res.set({
39 | 'X-XSS-Protection': '1; mode=block',
40 | 'X-Content-Type-Options': 'nosniff',
41 | 'X-Frame-Options': 'deny',
42 | 'Cache-Control': 'public, max-age=86400, s-maxage=86400',
43 | });
44 | res.removeHeader('X-Powered-By');
45 | next();
46 | });
47 |
48 | routes(app);
49 | app.listen(PORT, HOST);
50 |
51 | console.log(`Running on http://localhost:${PORT}`);
52 |
--------------------------------------------------------------------------------
/src/views/__tests__/topic.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils';
2 | import { createStore } from 'vuex';
3 | import { createRouter, createWebHistory } from 'vue-router';
4 | import Vue3Resize from 'vue3-resize';
5 | import clickOutside from '@/directives/clickOutside';
6 | import i18n from '@/i18n';
7 | import { routes } from '@/router';
8 | import mockStore from '@/../tests/MockStore';
9 | import GraphInstance from '@/components/GraphInstance.vue';
10 | import Topic from '../Topic.vue';
11 |
12 | describe('Topic', () => {
13 | let wrapper = null;
14 | let router = null;
15 | let store = null;
16 |
17 | beforeEach(async () => {
18 | store = createStore(mockStore);
19 | router = createRouter({
20 | history: createWebHistory(),
21 | routes,
22 | });
23 |
24 | GraphInstance.methods.draw = jest.fn();
25 |
26 | wrapper = mount(Topic, {
27 | global: {
28 | plugins: [router, store, i18n, Vue3Resize],
29 | directives: {
30 | 'click-outside': clickOutside,
31 | },
32 | stubs: {
33 | 'ok-icon': true,
34 | spinner: true,
35 | 'v-category': true,
36 | },
37 | },
38 | props: {
39 | district: 'gamleoslo',
40 | topic: 'alder',
41 | },
42 | });
43 | await router.push('/bydel/gamleoslo/alder');
44 | });
45 |
46 | afterEach(() => {
47 | wrapper.unmount();
48 | });
49 |
50 | test('renders topic-component and finds main-container class', () => {
51 | expect(wrapper.classes('main-container')).toBe(true);
52 | });
53 |
54 | test('renders component correctly', () => {
55 | expect(wrapper.element).toMatchSnapshot();
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/src/util/graph-templates/graph-helpers/rowHelpers/initRows.js:
--------------------------------------------------------------------------------
1 | import util from '@/util/graph-templates/template-utils';
2 | import { color } from '@/util/graph-templates/colors';
3 |
4 | export default function () {
5 | return this.canvas
6 | .select('g.rows')
7 | .selectAll('g.row')
8 | .data(this.filteredData.data, (d) => d.geography)
9 | .join(enterRowElements.bind(this), updateRowElements.bind(this))
10 | .classed('row', true)
11 | .attr('data-avgRow', (d) => d.avgRow)
12 | .attr('data-totalRow', (d) => d.totalRow);
13 | }
14 |
15 | function enterRowElements(selection) {
16 | const g = selection.append('g').attr('class', 'row');
17 |
18 | // Row fill
19 | g.insert('rect')
20 | .attr('class', 'rowFill')
21 | .attr('fill', color.purple)
22 | .attr('height', this.rowHeight)
23 | .attr('x', -this.padding.left)
24 | .attr('width', this.width + this.padding.left);
25 |
26 | // Row divider
27 | g.insert('rect')
28 | .attr('class', 'divider')
29 | .attr('fill', color.purple)
30 | .attr('x', -this.padding.left)
31 | .attr('width', this.width + this.padding.left)
32 | .attr('height', 1)
33 | .attr('y', this.rowHeight);
34 |
35 | // Text element
36 | g.append('text')
37 | .attr('class', 'geography')
38 | .attr('fill', color.purple)
39 | .attr('y', this.rowHeight / 2 + 6)
40 | .on('click', util.goto);
41 |
42 | g.append('g').attr('class', 'bars');
43 |
44 | g.append('text').attr('class', 'valueText').attr('fill', color.purple);
45 |
46 | g.attr('transform', (d, i) => `translate(0, ${i * this.rowHeight})`);
47 |
48 | return g;
49 | }
50 |
51 | function updateRowElements(selection) {
52 | return selection.transition().attr('transform', (d, i) => `translate(0, ${i * this.rowHeight})`);
53 | }
54 |
--------------------------------------------------------------------------------
/src/styles/_table.scss:
--------------------------------------------------------------------------------
1 | @use './colors' as *;
2 | @use './variables' as *;
3 |
4 | table {
5 | position: relative;
6 | width: 100%;
7 | overflow-x: scroll;
8 | border-collapse: collapse;
9 |
10 | td,
11 | th {
12 | position: relative;
13 | padding: 0.6em 1em;
14 | font-weight: 400;
15 | white-space: nowrap;
16 | text-align: right;
17 | border-left: 1px solid lighten($color-purple, 65%);
18 | }
19 |
20 | thead th:not([colspan='1']) {
21 | text-align: center;
22 | }
23 |
24 | thead tr:first-child th:first-child {
25 | background-color: lighten($color-yellow, 25%) !important;
26 | }
27 |
28 | thead tr:first-child th:first-child,
29 | th[scope='row'] {
30 | position: sticky;
31 | left: 0;
32 | z-index: 1;
33 | max-width: 25em;
34 | font-weight: 500;
35 | text-align: left;
36 | background: lighten($color-yellow, 10%);
37 | border-left: none;
38 | }
39 |
40 | thead {
41 | background: lighten($color-yellow, 25%);
42 | }
43 |
44 | .border-cell:not(:first-child) {
45 | border-left: 1px solid $color-purple;
46 | }
47 |
48 | tbody tr:hover td {
49 | background: lighten($color-purple, 72%);
50 | }
51 |
52 | tbody tr:hover th {
53 | background: lighten($color-yellow, 5%);
54 | }
55 | }
56 |
57 | .table-heading {
58 | position: sticky;
59 | left: 1rem;
60 | max-width: 80vw;
61 | margin: 1rem;
62 | color: $color-purple;
63 | font-weight: 500;
64 | font-size: 14px;
65 | text-align: left;
66 | }
67 |
68 | .tableexport-caption {
69 | position: sticky;
70 | left: 0;
71 | width: 30rem;
72 | padding: 1rem 1rem 2rem;
73 | text-align: left;
74 |
75 | button {
76 | display: inline-block;
77 | margin-left: 0.5rem;
78 | padding: 0.45rem 0.75rem;
79 | border: 1px solid $color-purple;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/config/topics.js:
--------------------------------------------------------------------------------
1 | import alder from './topics/alder';
2 | import fodteDodeFlytting from './topics/fodteDodeFlytting';
3 | import boligpriser from './topics/boligpriser';
4 | import bygningstyper from './topics/bygningstyper';
5 | import eierform from './topics/eierform';
6 | import befolkningsutvikling from './topics/befolkningsutvikling';
7 | import husholdninger from './topics/husholdninger';
8 | import innvandrerbefolkningen from './topics/innvandrerbefolkningen';
9 | import levekaar from './topics/levekaar';
10 | import romPerPerson from './topics/romPerPerson';
11 | import utdanning from './topics/utdanning';
12 |
13 | export const topicNames = [
14 | 'befolkningsutvikling',
15 | 'fodte-dode-flytting',
16 | 'alder',
17 | 'innvandrerbefolkningen',
18 | 'husholdninger',
19 | 'levekaar',
20 | 'eierform',
21 | 'bygningstyper',
22 | 'boligpriser',
23 | 'rom-per-person',
24 | 'utdanning',
25 | ];
26 |
27 | export const disabledTopics = [];
28 |
29 | export const categories = [
30 | {
31 | kategori: 'Befolkning',
32 | color: 'rgb(182, 63, 50)',
33 | links: ['befolkningsutvikling', 'fodte-dode-flytting', 'alder', 'innvandrerbefolkningen', 'husholdninger'],
34 | },
35 | {
36 | kategori: 'Boforhold',
37 | color: 'rgb(27, 173, 120)',
38 | links: ['rom-per-person', 'eierform', 'bygningstyper', 'boligpriser'],
39 | },
40 | {
41 | kategori: 'Levekår',
42 | color: 'rgb(219, 160, 52)',
43 | links: ['levekaar'],
44 | },
45 | {
46 | kategori: 'Utdanning',
47 | color: 'rgb(89, 186, 204)',
48 | links: ['utdanning'],
49 | },
50 | ];
51 |
52 | export const topics = {
53 | alder,
54 | 'fodte-dode-flytting': fodteDodeFlytting,
55 | boligpriser,
56 | bygningstyper,
57 | eierform,
58 | befolkningsutvikling,
59 | husholdninger,
60 | innvandrerbefolkningen,
61 | levekaar,
62 | 'rom-per-person': romPerPerson,
63 | utdanning,
64 | };
65 |
--------------------------------------------------------------------------------
/src/components/__tests__/navigationTopbar.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils';
2 | import router from '@/router';
3 | import clickOutside from '@/directives/clickOutside';
4 | import store from '@/store';
5 | import i18n from '@/i18n';
6 | import TheNavigationTopbar from '../TheNavigationTopbar.vue';
7 |
8 | global.scroll = jest.fn();
9 | window.scroll = jest.fn();
10 |
11 | describe('TheNavigationTopbar', () => {
12 | let wrapper = null;
13 |
14 | beforeEach(() => {
15 | wrapper = mount(TheNavigationTopbar, {
16 | global: {
17 | plugins: [router, store, i18n],
18 | directives: {
19 | 'click-outside': clickOutside,
20 | },
21 | stubs: {
22 | 'ok-icon': true,
23 | },
24 | },
25 | });
26 | });
27 |
28 | afterEach(() => {
29 | wrapper.unmount();
30 | });
31 |
32 | test('renders navigationDrawer-component and finds oslo__navigation-topbar-class', () => {
33 | expect(wrapper.classes('oslo__navigation-topbar')).toBe(true);
34 | });
35 |
36 | test('renders correctly', async () => {
37 | await router.push('/bydel/sagene');
38 | expect(wrapper.element).toMatchSnapshot();
39 | });
40 |
41 | test('return false if subpage is not active', async () => {
42 | await router.push('/bydel/sagene/boligpriser');
43 | expect(wrapper.vm.checkActiveTopic('levekaar')).toEqual(false);
44 | });
45 |
46 | test('change showDropdown to false if it is true', async () => {
47 | wrapper.setData({ showDropdown: true });
48 | wrapper.vm.closeMenu();
49 | expect(wrapper.vm.showDropdown).toEqual(false);
50 | });
51 |
52 | test('keep showDropdown as false if false', async () => {
53 | wrapper.vm.closeMenu();
54 | expect(wrapper.vm.showDropdown).toEqual(false);
55 | });
56 |
57 | test('return router object when clicking on a subpage', async () => {
58 | await router.push('/bydel/sagene/boligpriser');
59 | expect(wrapper.vm.onClickTopic('boligpriser')).toEqual({
60 | name: 'Topic',
61 | params: { district: 'sagene', topic: 'boligpriser' },
62 | });
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/src/views/Topic.vue:
--------------------------------------------------------------------------------
1 |
2 |
24 |
25 |
26 |
70 |
71 |
76 |
--------------------------------------------------------------------------------
/server/routes.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 |
3 | const https = require('https');
4 | const http = require('http');
5 | const express = require('express');
6 | const path = require('path');
7 | const auth = require('./auth');
8 |
9 | const api = process.env.BYDELSFAKTA_API_URL;
10 | const API_URL = api.slice(-1) === '/' ? api : `${api}/`;
11 | const get = API_URL[4] === 's' ? https.get : http.get;
12 |
13 | module.exports = (app) => {
14 | app.use('/', express.static(path.join(__dirname, '../docs/')));
15 |
16 | app.get('/health', (req, res) => {
17 | res.send('UP');
18 | });
19 |
20 | app.get('/.well-known/security.txt', (req, res) => {
21 | res.redirect('https://www.oslo.kommune.no/.well-known/security.txt');
22 | });
23 |
24 | app.get('/api/dataset/:dataset', auth(), (req, res) => {
25 | const headers = {};
26 | // eslint-disable-next-line no-restricted-syntax
27 | for (const k in req.headers) {
28 | if (['host', 'connection', 'content-length'].indexOf(k) === -1) {
29 | headers[k] = req.headers[k];
30 | }
31 | }
32 |
33 | get(
34 | `${API_URL}${req.params.dataset}?geography=${req.query.geography}`,
35 | {
36 | headers,
37 | },
38 | (proxyRes) => {
39 | const { statusCode } = proxyRes;
40 | const newHeaders = proxyRes.headers;
41 | const contentType = newHeaders['content-type'];
42 |
43 | if (statusCode !== 200) {
44 | console.error('Unexpected status:', statusCode);
45 | }
46 | if (!/^application\/json/.test(contentType)) {
47 | console.error('Unexpected content-type:', contentType);
48 | }
49 |
50 | res.writeHead(statusCode, newHeaders);
51 | proxyRes.pipe(res);
52 | }
53 | );
54 | });
55 |
56 | app.use('*', express.static(path.join(__dirname, '../docs/')));
57 |
58 | const allConfiguredRoutes = () => {
59 | const routes = [];
60 | app._router.stack.forEach((layer) => {
61 | if (layer && layer.route) {
62 | routes.push(layer.route.path);
63 | }
64 | });
65 | return { routes };
66 | };
67 |
68 | console.log(allConfiguredRoutes());
69 | };
70 |
--------------------------------------------------------------------------------
/server/auth/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | const { request } = require('./api');
3 | const { shouldRefreshToken, addExpirationInformation } = require('./accessToken');
4 |
5 | const data = {
6 | client_id: process.env.KEYCLOAK_CLIENT_ID,
7 | client_secret: process.env.KEYCLOAK_CLIENT_SECRET,
8 | };
9 |
10 | let accessToken = null;
11 |
12 | const logErrors = (error, refresh) => {
13 | if (refresh) {
14 | console.error('Status: ', error.status);
15 | console.error('StatusText: ', error.statusText);
16 | console.error('Error refreshing access token: ', error.data);
17 | } else {
18 | console.error('Status: ', error.status);
19 | console.error('StatusText: ', error.statusText);
20 | console.error('Error getting access token: ', error.data);
21 | }
22 | };
23 |
24 | module.exports = () => {
25 | return (req, res, next) => {
26 | if (accessToken === null || shouldRefreshToken(accessToken.refresh_expires_at)) {
27 | const params = { ...data, grant_type: process.env.KEYCLOAK_GRANT_TYPE_CLIENT };
28 |
29 | request(params)
30 | .then((response) => {
31 | accessToken = addExpirationInformation(response);
32 | req.headers.authorization = `Bearer ${accessToken.access_token}`;
33 | next();
34 | })
35 | .catch((error) => {
36 | logErrors(error);
37 | return res.status(error.status);
38 | });
39 | } else if (shouldRefreshToken(accessToken.expires_at)) {
40 | const params = {
41 | ...data,
42 | grant_type: process.env.KEYCLOAK_GRANT_TYPE_REFRESH,
43 | refresh_token: accessToken.refresh_token,
44 | };
45 |
46 | request(params)
47 | .then((response) => {
48 | accessToken = addExpirationInformation(response);
49 | req.headers.authorization = `Bearer ${accessToken.access_token}`;
50 | next();
51 | })
52 | .catch((error) => {
53 | logErrors(error, true);
54 | return res.status(error.status);
55 | });
56 | } else {
57 | req.headers.authorization = `Bearer ${accessToken.access_token}`;
58 | next();
59 | }
60 | };
61 | };
62 |
--------------------------------------------------------------------------------
/src/views/District.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
77 |
--------------------------------------------------------------------------------
/.stylelintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: [
3 | /*
4 | The recommended shareable SCSS config for Stylelint. Extends both
5 | the `stylelint-config-recommended` shared config and configures its
6 | rules for SCSS. Bundles and configures the `stylelint-scss plugin`
7 | pack and the `postcss-scss` custom syntax.
8 | https://github.com/stylelint-scss/stylelint-config-standard-scss
9 | */
10 | 'stylelint-config-recommended-scss',
11 | /*
12 | Recommended shareable Vue config for Stylelint. Extends the
13 | `stylelint-config-recommended` shared config and bundles
14 | the `postcss-html` custom syntax and configures it.
15 | https://github.com/ota-meshi/stylelint-config-recommended-vue
16 | */
17 | 'stylelint-config-recommended-vue',
18 | /*
19 | Turns off all CSS and SCSS rules that are unnecessary or might
20 | conflict with Prettier.
21 | https://github.com/prettier/stylelint-config-prettier-scss
22 | */
23 | // 'stylelint-config-prettier-scss',
24 | /*
25 | Stylelint config for "rational" ordering of property declarations.
26 | https://github.com/Allohamora/stylelint-config-rational-order
27 | */
28 | 'stylelint-config-rational-order-fix',
29 | ],
30 | plugins: [
31 | /*
32 | Collection of SCSS specific linting rules for Stylelint.
33 | https://github.com/stylelint-scss/stylelint-scss
34 | */
35 | 'stylelint-scss',
36 | /*
37 | Plugin pack of order-related linting rules for Stylelint.
38 | https://github.com/hudochenkov/stylelint-order
39 | */
40 | 'stylelint-order',
41 | ],
42 | rules: {
43 | 'color-hex-length': 'long',
44 | 'color-function-notation': 'legacy',
45 | 'at-rule-no-unknown': null,
46 | 'scss/at-rule-no-unknown': true,
47 | 'no-descending-specificity': null,
48 | 'function-no-unknown': null,
49 | 'scss/no-global-function-names': null,
50 | 'selector-class-pattern': null,
51 | 'scss/at-function-pattern': null,
52 | 'scss/comment-no-empty': null,
53 | 'shorthand-property-no-redundant-values': null,
54 | 'length-zero-no-unit': null,
55 | 'alpha-value-notation': null,
56 | 'font-family-name-quotes': 'always-unless-keyword',
57 | 'font-family-no-missing-generic-family-keyword': true,
58 | },
59 | }
60 |
--------------------------------------------------------------------------------
/src/config/topics/befolkningsutvikling.js:
--------------------------------------------------------------------------------
1 | import { apiUrl, baseUrl } from '../../util/config';
2 | import source from './dataSources';
3 |
4 | const API = `${apiUrl}/api/dataset`;
5 |
6 | export default {
7 | text: 'Befolkningsutvikling',
8 | value: 'befolkningsutvikling',
9 | cards: [
10 | {
11 | size: 'large',
12 | heading: 'Befolkningsutvikling',
13 | about: {
14 | info: 'Statistikken viser folkemengden per 1.1. hvert år. For flere befolkningstabeller se under «Befolkning» i Oslo kommunes statistikkbank.',
15 | sources: [source.ssb],
16 | externalInfo: 'https://statistikkbanken.oslo.kommune.no/statbank/pxweb/no/db1/db1__Befolkning__Folkemengde',
17 | },
18 | tabs: [
19 | {
20 | label: 'Folkemengde',
21 | heading: 'Befolkningsutvikling',
22 | id: 'befolkningsutvikling_antall',
23 | method: 'value',
24 | template: 'lines',
25 | url: `${API}/folkemengde-utvikling-historisk`,
26 | },
27 |
28 | {
29 | heading: 'Prosentvis årlig utvikling i folkemengde',
30 | label: 'Prosentvis endring',
31 | id: 'befolkningsutvikling_andel',
32 | method: 'ratio',
33 | template: 'lines',
34 | url: `${API}/folkemengde-utvikling-historisk-prosent`,
35 | },
36 | ],
37 | },
38 | {
39 | size: 'large',
40 | heading: 'Befolkningsvekst',
41 | about: {
42 | info: 'Statistikken viser folkemengden per 1.1. hvert år. For flere befolkningstabeller se under «Befolkning» i Oslo kommunes statistikkbank.',
43 | sources: [source.ssb],
44 | externalInfo: 'https://statistikkbanken.oslo.kommune.no/statbank/pxweb/no/db1/db1__Befolkning__Folkemengde',
45 | },
46 | tabs: [
47 | {
48 | label: 'Status',
49 | heading: 'Befolkningsvekst',
50 | id: 'befolkningsvekst',
51 | url: `${API}/nokkeltall-om-befolkningen`,
52 | template: 'populationDetailsTable',
53 | },
54 | ],
55 | },
56 | ],
57 | related: ['alder', 'innvandrerbefolkningen', 'fodte-dode-flytting'],
58 | options: {
59 | kategori: 'Befolkning',
60 | tema: 'Befolkningsutvikling',
61 | bgImage: `${baseUrl}/img/folkemengde`,
62 | txtColor: 'rgb(245, 173, 165)',
63 | },
64 | };
65 |
--------------------------------------------------------------------------------
/src/router.js:
--------------------------------------------------------------------------------
1 | import { createRouter, createWebHistory } from 'vue-router';
2 |
3 | import { topicNames, disabledTopics } from './config/topics';
4 | import allDistricts from './config/allDistricts';
5 |
6 | export const routes = [
7 | {
8 | path: '/',
9 | name: 'Home',
10 | component: () => import('@/views/District.vue'),
11 | },
12 | {
13 | path: '/bydel/:district?',
14 | name: 'District',
15 | component: () => import('@/views/District.vue'),
16 | props: true,
17 | },
18 | {
19 | path: '/bydel/:district/:topic',
20 | name: 'Topic',
21 | component: () => import('@/views/Topic.vue'),
22 | props: true,
23 | },
24 | {
25 | path: '/:pathMatch(.*)*',
26 | name: 'NotFound',
27 | component: () => import('@/views/NotFound.vue'),
28 | },
29 | ];
30 |
31 | const router = createRouter({
32 | history: createWebHistory(),
33 | routes,
34 | });
35 |
36 | // Temp while we find out what our homepage should look like
37 | router.beforeEach((to, from) => {
38 | if (to.params.topic) {
39 | if (
40 | (!topicNames.find((name) => name === to.params.topic) && import.meta.env.VITE_PRODUCTION_DATA === 'prod') ||
41 | disabledTopics.includes(to.params.topic)
42 | ) {
43 | return { name: 'NotFound', params: [to.path] };
44 | }
45 | }
46 | if (to.params.district) {
47 | const districts = to.params.district.split('-');
48 | if (districts[0] === 'alle') {
49 | return true;
50 | }
51 | if (districts.length === 1) {
52 | if (allDistricts.find((district) => district.uri === districts[0])) {
53 | return true;
54 | }
55 | if (allDistricts.find((district) => district.key === districts[0])) {
56 | return true;
57 | }
58 | return { name: 'NotFound', params: [to.path] };
59 | }
60 | if (districts.length > 1) {
61 | const errors = [];
62 | districts.forEach((district) => {
63 | if (!allDistricts.find((item) => item.key === district)) {
64 | errors.push(district);
65 | }
66 | });
67 | if (errors.length > 0) {
68 | return { name: 'NotFound', params: [to.path] };
69 | }
70 | }
71 | }
72 | if (to.params.topic !== from.params.topic || (to.name === 'Topic' && from.name === 'District')) {
73 | window.scroll(0, 0);
74 | }
75 | return true;
76 | });
77 |
78 | export default router;
79 |
--------------------------------------------------------------------------------
/src/components/UxSignals.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
23 |
24 |
108 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | node: true,
5 | jest: true,
6 | },
7 | extends: [
8 | /*
9 | Provides Airbnb's base JS .eslintrc as an extensible shared config.
10 | https://github.com/airbnb/javascript
11 | */
12 | 'airbnb-base',
13 | /*
14 | Use the recommended rule preset for `eslint-plugin-vue`.
15 | https://eslint.vuejs.org/rules/
16 | */
17 | 'plugin:vue/vue3-recommended',
18 | /*
19 | Use as the last extension in order to override conflicting ESLint rules.
20 | https://github.com/prettier/eslint-plugin-prettier
21 | */
22 | 'plugin:prettier/recommended',
23 | ],
24 | plugins: [
25 | /*
26 | Official ESLint plugin for Vue.js. Allows us to check the `` and
27 | `
53 |
54 |
114 |
--------------------------------------------------------------------------------
/src/components/VCategory.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{ category }}
9 |
10 |
11 |
12 |
13 |
54 |
55 |
140 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Bydelsfakta
21 |
25 |
29 |
30 |
31 |
32 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
49 |
50 |
51 |
61 |
62 |
63 |
64 | We're sorry but bydelsfakta doesn't work properly without JavaScript enabled. Please enable it to
66 | continue.
68 |
69 |
70 |
71 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/src/styles/main.scss:
--------------------------------------------------------------------------------
1 | @use 'colors';
2 | @use 'fonts';
3 | @use 'layout';
4 | @use 'animations';
5 | @use 'variables';
6 | @use 'typography';
7 | @use 'table';
8 | @use 'print';
9 |
10 | *,
11 | ::before,
12 | ::after {
13 | box-sizing: inherit;
14 | }
15 |
16 | html {
17 | box-sizing: border-box;
18 | overflow-y: scroll;
19 | font-size: 12.5px;
20 | background-color: colors.$color-bg;
21 | -webkit-font-smoothing: antialiased;
22 |
23 | @media screen and (min-width: 600px) {
24 | font-size: 13px;
25 | }
26 |
27 | @media screen and (min-width: variables.$break-md) {
28 | font-size: 14px;
29 | }
30 | }
31 |
32 | body {
33 | min-height: 100vh;
34 | margin: 0;
35 | font-family: 'OsloSans', sans-serif;
36 | }
37 |
38 | button,
39 | input,
40 | select {
41 | font: inherit;
42 | background-color: transparent;
43 | border-style: none;
44 | }
45 |
46 | button:disabled > * {
47 | opacity: 0.35;
48 | }
49 |
50 | .visually-hidden {
51 | position: absolute;
52 | top: auto;
53 | left: -10000px;
54 | width: 1px;
55 | height: 1px;
56 | overflow: hidden;
57 | }
58 |
59 | .hidden {
60 | position: fixed !important;
61 | visibility: hidden;
62 | opacity: 0;
63 | }
64 |
65 | // Dropdown menu on template D
66 | .graph__dropdown {
67 | position: absolute;
68 | top: 9.75em;
69 | left: 0;
70 | padding: 1em;
71 |
72 | &__label {
73 | display: block;
74 | }
75 |
76 | &__select {
77 | position: relative;
78 | width: 7em;
79 | height: 3em;
80 | margin-top: 0.5em;
81 | padding-left: 0.5em;
82 | border: 1px solid black;
83 |
84 | &::before,
85 | &::after {
86 | position: absolute;
87 | top: 15px;
88 | left: 15px;
89 | display: block;
90 | width: 15px;
91 | height: 15px;
92 | background: black;
93 | content: '';
94 | }
95 | }
96 | }
97 |
98 | /* The default outline styling, for greatest accessibility. */
99 |
100 | /* You can skip this to just use the browser's defaults. */
101 | :focus {
102 | outline: #0088ff auto 2px;
103 | }
104 |
105 | /* When mouse is detected, ALL focused elements have outline removed. */
106 |
107 | /* You could apply this selector only to buttons, if you wanted. */
108 | body.using-mouse :focus {
109 | outline: none;
110 | }
111 |
112 | .graphlegend {
113 | display: flex;
114 | flex-direction: row;
115 | flex-wrap: wrap;
116 | justify-content: center;
117 | width: 100%;
118 |
119 | &__item {
120 | display: flex;
121 | align-items: center;
122 | padding: 0 1rem;
123 | white-space: nowrap;
124 | }
125 |
126 | &__name {
127 | white-space: nowrap;
128 | }
129 |
130 | &__swatch {
131 | display: inline-block;
132 | width: 1rem;
133 | height: 1rem;
134 | margin-right: 0.4rem;
135 | }
136 | }
137 |
138 | select {
139 | position: relative;
140 | width: 100%;
141 | padding: 0.5rem 1rem;
142 | font-size: 1rem;
143 | border: 1px solid rgba(black, 0.1);
144 | border-radius: 1px;
145 | -webkit-appearance: none;
146 |
147 | &::after {
148 | position: absolute;
149 | top: 0;
150 | right: 0;
151 | bottom: 0;
152 | z-index: 2;
153 | display: block;
154 | width: 20px;
155 | height: 20px;
156 | background: red;
157 | content: '';
158 | }
159 | }
160 |
161 | svg {
162 | rect {
163 | shape-rendering: crispEdges;
164 | }
165 |
166 | .tick > * {
167 | shape-rendering: crispEdges;
168 | }
169 |
170 | .domain {
171 | shape-rendering: crispEdges;
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/src/config/topics/alder.js:
--------------------------------------------------------------------------------
1 | import { apiUrl, baseUrl } from '../../util/config';
2 | import source from './dataSources';
3 |
4 | const API = `${apiUrl}/api/dataset`;
5 |
6 | export default {
7 | text: 'Kjønn og alder',
8 | value: 'alder',
9 | cards: [
10 | {
11 | size: 'large',
12 | heading: 'Befolkningen etter alder',
13 | about: {
14 | info: 'Statistikken viser folkemengden per 1.1. hvert år. For flere befolkningstabeller se under «Befolkning» i Oslo kommunes statistikkbank.',
15 | externalInfo: 'https://statistikkbanken.oslo.kommune.no/statbank/pxweb/no/db1/db1__Befolkning__Folkemengde',
16 | sources: [source.ssb, source.oslo],
17 | },
18 | map: false,
19 | tabs: [
20 | {
21 | active: false,
22 | label: 'Antall',
23 | id: 'alder_segment_antall',
24 | help: 'Bruk slideren eller rullegardinlisten til å utforske et bestemt alderssegment.',
25 | template: 'ageDistribution',
26 | heading: 'Aldersfordeling (antall)',
27 | url: `${API}/alder-distribusjon-status`,
28 | method: 'value',
29 | },
30 | {
31 | active: false,
32 | label: 'Andel',
33 | id: 'alder_segment_andel',
34 | help: 'Bruk slideren eller rullegardinlisten til å utforske et bestemt alderssegment.',
35 | template: 'ageDistribution',
36 | heading: 'Aldersfordeling (andel)',
37 | url: `${API}/alder-distribusjon-status`,
38 | method: 'ratio',
39 | },
40 | ],
41 | },
42 | {
43 | size: 'large',
44 | heading: 'Befolkningspyramide',
45 | about: {
46 | info: 'Statistikken viser folkemengden per 1.1. hvert år. For flere befolkningstabeller se under «Befolkning» i Oslo kommunes statistikkbank.',
47 | externalInfo: 'https://statistikkbanken.oslo.kommune.no/statbank/pxweb/no/db1/db1__Befolkning__Folkemengde',
48 | sources: [source.ssb, source.oslo],
49 | },
50 | map: false,
51 | tabs: [
52 | {
53 | active: false,
54 | label: 'Status',
55 | id: 'alder_befolkningspyramide_status',
56 | template: 'pyramid',
57 | heading: 'Befolkningen etter alder og kjønn',
58 | method: 'value',
59 | url: `${API}/alder-distribusjon-status`,
60 | },
61 | ],
62 | },
63 | {
64 | size: 'large',
65 | heading: 'Gjennomsnitt- og medianalder',
66 | about: {
67 | info: 'Statistikken viser folkemengden per 1.1. hvert år. For flere befolkningstabeller se under «Befolkning» i Oslo kommunes statistikkbank.',
68 | externalInfo: 'https://statistikkbanken.oslo.kommune.no/statbank/pxweb/no/db1/db1__Befolkning__Folkemengde',
69 | sources: [source.ssb, source.oslo],
70 | },
71 | map: {
72 | labels: ['Lavere gjennomsnittsalder', 'Høyere gjennomsnittsalder'],
73 | reverse: true,
74 | method: 'avg',
75 | url: `${API}/alder-distribusjon-status`,
76 | },
77 | tabs: [
78 | {
79 | active: false,
80 | label: 'Status',
81 | id: 'alder_medianalder_status',
82 | method: 'total',
83 | heading: 'Gjennomsnitts- og medianalder',
84 | template: 'boxPlot',
85 | url: `${API}/alder-distribusjon-status`,
86 | },
87 | ],
88 | },
89 | ],
90 | options: {
91 | kategori: 'Befolkning',
92 | tema: 'Kjønn og alder',
93 | bgImage: `${baseUrl}/img/alder`,
94 | txtColor: 'rgb(245, 173, 165)',
95 | },
96 | related: ['befolkningsutvikling', 'husholdninger', 'levekaar'],
97 | };
98 |
--------------------------------------------------------------------------------
/src/config/districtNames.js:
--------------------------------------------------------------------------------
1 | export default {
2 | '00': 'Oslo i alt',
3 | '01': 'Bydel Gamle Oslo',
4 | '02': 'Bydel Grünerløkka',
5 | '03': 'Bydel Sagene',
6 | '04': 'Bydel St. Hanshaugen',
7 | '05': 'Bydel Frogner',
8 | '06': 'Bydel Ullern',
9 | '07': 'Bydel Vestre Aker',
10 | '08': 'Bydel Nordre Aker',
11 | '09': 'Bydel Bjerke',
12 | 10: 'Bydel Grorud',
13 | 11: 'Bydel Stovner',
14 | 12: 'Bydel Alna',
15 | 13: 'Bydel Østensjø',
16 | 14: 'Bydel Nordstrand',
17 | 15: 'Bydel Søndre Nordstrand',
18 | 16: 'Sentrum',
19 | 17: 'Marka',
20 | '0301010101': 'Lodalen',
21 | '0301010102': 'Grønland',
22 | '0301010103': 'Enerhaugen',
23 | '0301010104': 'Nedre Tøyen',
24 | '0301010105': 'Kampen',
25 | '0301010106': 'Vålerenga',
26 | '0301010107': 'Helsfyr',
27 | '0301010108': 'Kværnerbyen',
28 | '0301010109': 'Bispevika',
29 | '0301010110': 'Ensjø',
30 | '0301010111': 'Etterstad',
31 | '0301020201': 'Grünerløkka vest',
32 | '0301020202': 'Grünerløkka øst',
33 | '0301020203': 'Dælenenga',
34 | '0301020204': 'Rodeløkka',
35 | '0301020205': 'Sinsen',
36 | '0301020206': 'Sofienberg',
37 | '0301020207': 'Hasle-Løren',
38 | '0301020208': 'Løren',
39 | '0301020209': 'Hasle',
40 | '0301030301': 'Iladalen',
41 | '0301030302': 'Sagene',
42 | '0301030303': 'Bjølsen',
43 | '0301030304': 'Sandaker',
44 | '0301030305': 'Torshov',
45 | '0301040401': 'Hammersborg',
46 | '0301040402': 'Bislett',
47 | '0301040403': 'Ila',
48 | '0301040404': 'Fagerborg',
49 | '0301040405': 'Lindern',
50 | '0301050501': 'Bygdøy',
51 | '0301050502': 'Frogner',
52 | '0301050503': 'Frognerparken',
53 | '0301050504': 'Majorstuen nord',
54 | '0301050505': 'Majorstuen syd',
55 | '0301050506': 'Homansbyen',
56 | '0301050507': 'Uranienborg',
57 | '0301050508': 'Skillebekk',
58 | '0301060601': 'Ullernåsen',
59 | '0301060602': 'Lilleaker',
60 | '0301060603': 'Ullern',
61 | '0301060604': 'Montebello-Hoff',
62 | '0301060605': 'Skøyen',
63 | '0301070701': 'Røa',
64 | '0301070702': 'Holmenkollen',
65 | '0301070703': 'Hovseter',
66 | '0301070704': 'Holmen',
67 | '0301070705': 'Slemdal',
68 | '0301070706': 'Grimelund',
69 | '0301070707': 'Vinderen',
70 | '0301080801': 'Disen',
71 | '0301080802': 'Myrer',
72 | '0301080803': 'Grefsen',
73 | '0301080804': 'Kjelsås',
74 | '0301080805': 'Korsvoll',
75 | '0301080806': 'Tåsen',
76 | '0301080807': 'Nordberg',
77 | '0301080808': 'Ullevål hageby',
78 | '0301090901': 'Veitvet',
79 | '0301090902': 'Linderud',
80 | '0301090903': 'Økern',
81 | '0301090904': 'Årvoll',
82 | '0301090905': 'Refstad',
83 | '0301090906': 'Ulven',
84 | '0301101001': 'Ammerud',
85 | '0301101002': 'Rødtvet',
86 | '0301101003': 'Nordtvet',
87 | '0301101004': 'Grorud',
88 | '0301101005': 'Romsås',
89 | '0301111101': 'Vestli',
90 | '0301111102': 'Fossum',
91 | '0301111103': 'Rommen',
92 | '0301111104': 'Haugenstua',
93 | '0301111105': 'Stovner',
94 | '0301111106': 'Høybråten',
95 | '0301121201': 'Furuset',
96 | '0301121202': 'Ellingsrud',
97 | '0301121203': 'Lindeberg',
98 | '0301121204': 'Trosterud',
99 | '0301121205': 'Hellerudtoppen',
100 | '0301121206': 'Tveita',
101 | '0301121207': 'Teisen',
102 | '0301131301': 'Manglerud',
103 | '0301131302': 'Godlia',
104 | '0301131303': 'Oppsal',
105 | '0301131304': 'Bøler',
106 | '0301131305': 'Skullerud',
107 | '0301131306': 'Abildsø',
108 | '0301141401': 'Ljan',
109 | '0301141402': 'Nordstrand',
110 | '0301141403': 'Bekkelaget',
111 | '0301141404': 'Simensbråten',
112 | '0301141405': 'Lambertseter',
113 | '0301141406': 'Munkerud',
114 | '0301151501': 'Holmlia Syd',
115 | '0301151502': 'Holmlia Nord',
116 | '0301151503': 'Prinsdal',
117 | '0301151504': 'Bjørnerud',
118 | '0301151505': 'Mortensrud',
119 | '0301151506': 'Bjørndal',
120 | '0301161601': 'Sentrum',
121 | '0301171701': 'Marka',
122 | '0301999901': 'Uten registrert adresse',
123 | };
124 |
--------------------------------------------------------------------------------
/src/config/geoData/sagene.js:
--------------------------------------------------------------------------------
1 | export default {"type": "FeatureCollection", "name": "Bydel Sagene", "id": 3, "features": [{"type": "Feature", "properties": {"name": "Iladalen", "id": "0301030301", "bydel": 3}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[10.763881, 59.933866, 0.0], [10.761017, 59.930431, 0.0], [10.761084, 59.928497, 0.0], [10.749855, 59.928153, 0.0], [10.749123, 59.929823, 0.0], [10.748902, 59.931324, 0.0], [10.749554, 59.933207, 0.0], [10.749671, 59.934407, 0.0], [10.751708, 59.933917, 0.0], [10.757335, 59.933304, 0.0], [10.756867, 59.933997, 0.0], [10.757271, 59.935639, 0.0], [10.761345, 59.936625, 0.0], [10.764042, 59.934073, 0.0], [10.763881, 59.933866, 0.0]]]]}}, {"type": "Feature", "properties": {"name": "Sagene", "id": "0301030302", "bydel": 3}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[10.757567, 59.938918, 0.0], [10.759253, 59.938964, 0.0], [10.761345, 59.936625, 0.0], [10.757271, 59.935639, 0.0], [10.756867, 59.933997, 0.0], [10.757335, 59.933304, 0.0], [10.751708, 59.933917, 0.0], [10.749671, 59.934407, 0.0], [10.750186, 59.940037, 0.0], [10.750073, 59.940812, 0.0], [10.751784, 59.941266, 0.0], [10.752977, 59.940423, 0.0], [10.757567, 59.938918, 0.0]]]]}}, {"type": "Feature", "properties": {"name": "Bjølsen", "id": "0301030303", "bydel": 3}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[10.760158, 59.945671, 0.0], [10.760173, 59.945335, 0.0], [10.761922, 59.945931, 0.0], [10.764016, 59.94557, 0.0], [10.765029, 59.945628, 0.0], [10.765492, 59.944732, 0.0], [10.766268, 59.944155, 0.0], [10.766945, 59.943236, 0.0], [10.766619, 59.942249, 0.0], [10.765709, 59.941658, 0.0], [10.765765, 59.94146, 0.0], [10.767025, 59.940969, 0.0], [10.768446, 59.941156, 0.0], [10.7695, 59.940738, 0.0], [10.766543, 59.939334, 0.0], [10.763138, 59.938352, 0.0], [10.762915, 59.937992, 0.0], [10.763724, 59.937218, 0.0], [10.763634, 59.936867, 0.0], [10.761345, 59.936625, 0.0], [10.759253, 59.938964, 0.0], [10.758322, 59.939062, 0.0], [10.75785, 59.938839, 0.0], [10.755197, 59.939631, 0.0], [10.752645, 59.94056, 0.0], [10.751784, 59.941266, 0.0], [10.750073, 59.940812, 0.0], [10.748233, 59.943769, 0.0], [10.748404, 59.944457, 0.0], [10.748926, 59.944457, 0.0], [10.749303, 59.944021, 0.0], [10.75538, 59.945405, 0.0], [10.757846, 59.946449, 0.0], [10.760205, 59.945909, 0.0], [10.760158, 59.945671, 0.0]]]]}}, {"type": "Feature", "properties": {"name": "Sandaker", "id": "0301030304", "bydel": 3}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[10.776637, 59.94711, 0.0], [10.777075, 59.94684, 0.0], [10.777516, 59.946903, 0.0], [10.778212, 59.945924, 0.0], [10.780795, 59.940646, 0.0], [10.781327, 59.938994, 0.0], [10.778977, 59.938919, 0.0], [10.779238, 59.938395, 0.0], [10.779149, 59.937972, 0.0], [10.778085, 59.936894, 0.0], [10.777795, 59.936282, 0.0], [10.774047, 59.937804, 0.0], [10.774844, 59.939145, 0.0], [10.770367, 59.941503, 0.0], [10.767025, 59.940969, 0.0], [10.765697, 59.941539, 0.0], [10.766619, 59.942249, 0.0], [10.766945, 59.943236, 0.0], [10.765187, 59.945255, 0.0], [10.76494, 59.945971, 0.0], [10.765262, 59.946971, 0.0], [10.772196, 59.948187, 0.0], [10.774674, 59.94786, 0.0], [10.776637, 59.94711, 0.0]]]]}}, {"type": "Feature", "properties": {"name": "Torshov", "id": "0301030305", "bydel": 3}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[10.776952, 59.936484, 0.0], [10.777795, 59.936282, 0.0], [10.778085, 59.936894, 0.0], [10.778954, 59.937673, 0.0], [10.779238, 59.938395, 0.0], [10.778977, 59.938919, 0.0], [10.781327, 59.938994, 0.0], [10.782494, 59.936579, 0.0], [10.780088, 59.93527, 0.0], [10.779237, 59.934362, 0.0], [10.778613, 59.933157, 0.0], [10.776014, 59.931845, 0.0], [10.774221, 59.93125, 0.0], [10.771156, 59.930884, 0.0], [10.768614, 59.929593, 0.0], [10.765016, 59.931171, 0.0], [10.762263, 59.931923, 0.0], [10.764042, 59.934073, 0.0], [10.761345, 59.936625, 0.0], [10.763634, 59.936867, 0.0], [10.763724, 59.937218, 0.0], [10.762915, 59.937992, 0.0], [10.763138, 59.938352, 0.0], [10.768109, 59.939943, 0.0], [10.7695, 59.940738, 0.0], [10.768446, 59.941156, 0.0], [10.770367, 59.941503, 0.0], [10.774844, 59.939145, 0.0], [10.774047, 59.937804, 0.0], [10.77721, 59.936667, 0.0], [10.776952, 59.936484, 0.0]]]]}}]}
--------------------------------------------------------------------------------
/tests/MockStore.js:
--------------------------------------------------------------------------------
1 | import router from '../src/router';
2 |
3 | import districts from '../src/config/geoData/districts';
4 | import allDistricts from '../src/config/allDistricts';
5 |
6 | export const state = {
7 | compareDistricts: false,
8 | districts: ['01'],
9 | districtsGeo: districts,
10 | menuIsOpen: true,
11 | navigationIsOpen: false,
12 | isTouchDevice: false,
13 | ie11: false,
14 | productionMode: true, // null: development, false: test, true: prod
15 | };
16 |
17 | export const getters = {
18 | geoDistricts: (state) => {
19 | if (!state.compareDistricts && state.districts.length !== 0) {
20 | return { ...state.districtsGeo[`${allDistricts.find((district) => district.key === state.districts[0]).uri}`] };
21 | }
22 | if (state.districts[0] === 'alle') {
23 | return { ...state.districtsGeo.oslo };
24 | }
25 |
26 | const features = state.districts.map((id) =>
27 | state.districtsGeo.oslo.features.find((district) => district.properties.id === id)
28 | );
29 |
30 | return {
31 | ...state.districtsGeo.oslo,
32 | features,
33 | };
34 | },
35 | };
36 |
37 | export const mutations = {
38 | ADD_DISTRICT(state, payload) {
39 | state.compareDistricts = true;
40 | state.districts = payload;
41 | },
42 | SELECT_DISTRICT(state, payload) {
43 | state.compareDistricts = false;
44 | state.districts = payload;
45 | },
46 | CLEAN_STATE(state) {
47 | state.compareDistricts = false;
48 | state.districts = [];
49 | state.districtsGeo = districts;
50 | },
51 | SET_MENU_IS_OPEN(state, payload) {
52 | state.menuIsOpen = payload;
53 | },
54 | SET_NAVIGATION_IS_OPEN(state, payload) {
55 | state.navigationIsOpen = payload;
56 | },
57 | SET_TOUCH_DEVICE(state, payload) {
58 | state.isTouchDevice = payload;
59 | },
60 | SET_IE11_COMPATIBILITY(state, payload) {
61 | state.ie11 = payload;
62 | },
63 | SET_PRODUCTION_MODE(state, payload) {
64 | if (payload === 'prod') state.productionMode = true;
65 | else if (payload === 'dev') state.productionMode = false;
66 | else state.productionMode = null;
67 | },
68 | };
69 |
70 | export const actions = {
71 | addDistrict({ commit }, payload) {
72 | const payloadDistricts = payload.district.split('-');
73 | if (payloadDistricts.length === 1) {
74 | if (payloadDistricts[0] === 'alle') {
75 | commit('ADD_DISTRICT', districts);
76 | } else {
77 | const districtValue = allDistricts.find((district) => district.uri === payloadDistricts[0]);
78 | const districtKey = allDistricts.find((district) => district.key === payloadDistricts[0]);
79 |
80 | if (districtValue === undefined) commit('ADD_DISTRICT', [districtKey.key]);
81 | else commit('SELECT_DISTRICT', [districtValue.key]);
82 | }
83 | } else {
84 | commit('ADD_DISTRICT', districts);
85 | }
86 |
87 | if (payload.pushRoute) {
88 | if (router.currentRoute.value.params.topic === undefined) {
89 | router.push({ name: 'District', params: { district: districts.join('-') } });
90 | } else {
91 | router.push({
92 | name: 'Topic',
93 | params: { district: districts.join('-'), topic: router.currentRoute.value.params.topic },
94 | });
95 | }
96 | }
97 | },
98 |
99 | setTouchDevice({ commit }, payload) {
100 | commit('SET_TOUCH_DEVICE', payload);
101 | },
102 | cleanState({ commit }) {
103 | commit('CLEAN_STATE');
104 | },
105 | setMenuIsOpen({ commit }, payload) {
106 | commit('SET_MENU_IS_OPEN', payload);
107 | },
108 | setNavigationIsOpen({ commit }, payload) {
109 | commit('SET_NAVIGATION_IS_OPEN', payload);
110 | },
111 | setIE11Compatibility({ commit }, payload) {
112 | commit('SET_IE11_COMPATIBILITY', payload);
113 | },
114 | setProductionMode({ commit }, payload) {
115 | commit('SET_PRODUCTION_MODE', payload);
116 | },
117 | };
118 |
119 | export const storeStructure = {
120 | strict: true,
121 | state,
122 | getters,
123 | mutations,
124 | actions,
125 | };
126 |
127 | export default storeStructure;
128 |
--------------------------------------------------------------------------------
/src/components/PktIconsSprite.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
27 |
28 |
29 |
30 |
31 |
33 |
34 |
37 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
50 |
51 |
52 |
53 |
55 |
56 |
57 |
58 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/src/components/Modal.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
16 |
17 |
18 |
{{ $t('modal.subheader') }}
19 | {{ $t('modal.main') }}
20 |
21 |
22 |
59 |
60 |
61 |
62 |
63 |
82 |
83 |
161 |
--------------------------------------------------------------------------------
/src/store.js:
--------------------------------------------------------------------------------
1 | import { createStore } from 'vuex';
2 | import router from './router';
3 |
4 | import districts from './config/geoData/districts';
5 | import allDistricts from './config/allDistricts';
6 |
7 | export const state = {
8 | compareDistricts: false,
9 | districts: [],
10 | districtsGeo: districts,
11 | menuIsOpen: false,
12 | navigationIsOpen: false,
13 | isTouchDevice: false,
14 | ie11: false,
15 | productionMode: null, // null: development, false: test, true: prod
16 | };
17 |
18 | export const getters = {
19 | geoDistricts: (state) => {
20 | if (!state.compareDistricts && state.districts.length !== 0) {
21 | return { ...state.districtsGeo[`${allDistricts.find((district) => district.key === state.districts[0]).uri}`] };
22 | }
23 | if (state.districts[0] === 'alle') {
24 | return { ...state.districtsGeo.oslo };
25 | }
26 |
27 | const features = state.districts.map((id) =>
28 | state.districtsGeo.oslo.features.find((district) => district.properties.id === id)
29 | );
30 |
31 | return {
32 | ...state.districtsGeo.oslo,
33 | features,
34 | };
35 | },
36 | };
37 |
38 | export const mutations = {
39 | ADD_DISTRICT(state, payload) {
40 | state.compareDistricts = true;
41 | state.districts = payload;
42 | },
43 | SELECT_DISTRICT(state, payload) {
44 | state.compareDistricts = false;
45 | state.districts = payload;
46 | },
47 | CLEAN_STATE(state) {
48 | state.compareDistricts = false;
49 | state.districts = [];
50 | state.districtsGeo = districts;
51 | },
52 | SET_MENU_IS_OPEN(state, payload) {
53 | state.menuIsOpen = payload;
54 | },
55 | SET_NAVIGATION_IS_OPEN(state, payload) {
56 | state.navigationIsOpen = payload;
57 | },
58 | SET_TOUCH_DEVICE(state, payload) {
59 | state.isTouchDevice = payload;
60 | },
61 | SET_IE11_COMPATIBILITY(state, payload) {
62 | state.ie11 = payload;
63 | },
64 | SET_PRODUCTION_MODE(state, payload) {
65 | if (payload === 'prod') state.productionMode = true;
66 | else if (payload === 'dev') state.productionMode = false;
67 | else state.productionMode = null;
68 | },
69 | };
70 |
71 | export const actions = {
72 | addDistrict({ commit }, payload) {
73 | const payloadDistricts = payload.district.split('-');
74 | if (payloadDistricts.length === 1) {
75 | if (payloadDistricts[0] === 'alle') {
76 | commit('ADD_DISTRICT', payloadDistricts);
77 | } else {
78 | const districtValue = allDistricts.find((district) => district.uri === payloadDistricts[0]);
79 | const districtKey = allDistricts.find((district) => district.key === payloadDistricts[0]);
80 |
81 | if (districtValue === undefined) commit('ADD_DISTRICT', [districtKey.key]);
82 | else commit('SELECT_DISTRICT', [districtValue.key]);
83 | }
84 | } else {
85 | commit('ADD_DISTRICT', payloadDistricts);
86 | }
87 |
88 | if (payload.pushRoute) {
89 | if (router.currentRoute.value.params.topic === undefined) {
90 | router.push({ name: 'District', params: { district: payloadDistricts.join('-') } });
91 | } else {
92 | router.push({
93 | name: 'Topic',
94 | params: { district: payloadDistricts.join('-'), topic: router.currentRoute.value.params.topic },
95 | });
96 | }
97 | }
98 | },
99 |
100 | setTouchDevice({ commit }, payload) {
101 | commit('SET_TOUCH_DEVICE', payload);
102 | },
103 | cleanState({ commit }) {
104 | commit('CLEAN_STATE');
105 | },
106 | setMenuIsOpen({ commit }, payload) {
107 | commit('SET_MENU_IS_OPEN', payload);
108 | },
109 | setNavigationIsOpen({ commit }, payload) {
110 | commit('SET_NAVIGATION_IS_OPEN', payload);
111 | },
112 | setIE11Compatibility({ commit }, payload) {
113 | commit('SET_IE11_COMPATIBILITY', payload);
114 | },
115 | setProductionMode({ commit }, payload) {
116 | commit('SET_PRODUCTION_MODE', payload);
117 | },
118 | };
119 |
120 | export const storeStructure = {
121 | strict: true,
122 | state,
123 | getters,
124 | mutations,
125 | actions,
126 | };
127 |
128 | const store = createStore(storeStructure);
129 |
130 | export default store;
131 |
--------------------------------------------------------------------------------
/src/views/NotFound.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
17 |
26 |
27 |
28 |
29 | {{ $t('notFound.seeMore.subheader') }}
30 |
31 |
32 |
42 |
43 |
44 |
45 |
46 |
47 |
68 |
69 |
149 |
--------------------------------------------------------------------------------
/src/config/geoData/st_hanshaugen.js:
--------------------------------------------------------------------------------
1 | export default {"type": "FeatureCollection", "name": "Bydel St.Hanshaugen", "id": 4, "features": [{"type": "Feature", "properties": {"name": "Hammersborg", "id": "0301040401", "bydel": 4}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[10.741214, 59.922247, 0.0], [10.7421, 59.921057, 0.0], [10.743502, 59.919789, 0.0], [10.743698, 59.91925, 0.0], [10.744859, 59.919652, 0.0], [10.74604, 59.919734, 0.0], [10.746556, 59.920851, 0.0], [10.746833, 59.92087, 0.0], [10.747341, 59.920264, 0.0], [10.748221, 59.920343, 0.0], [10.749102, 59.92069, 0.0], [10.750353, 59.92169, 0.0], [10.750699, 59.921696, 0.0], [10.751138, 59.921226, 0.0], [10.747989, 59.919635, 0.0], [10.751696, 59.919187, 0.0], [10.757004, 59.916075, 0.0], [10.754527, 59.914958, 0.0], [10.749913, 59.913495, 0.0], [10.747776, 59.912868, 0.0], [10.745928, 59.912818, 0.0], [10.741544, 59.914462, 0.0], [10.74057, 59.916542, 0.0], [10.736875, 59.918653, 0.0], [10.735487, 59.920035, 0.0], [10.73374, 59.920661, 0.0], [10.73506, 59.921235, 0.0], [10.738085, 59.921361, 0.0], [10.741214, 59.922247, 0.0]]]]}}, {"type": "Feature", "properties": {"name": "Bislett", "id": "0301040402", "bydel": 4}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[10.738085, 59.921361, 0.0], [10.73506, 59.921235, 0.0], [10.73374, 59.920661, 0.0], [10.732829, 59.920984, 0.0], [10.732096, 59.922034, 0.0], [10.731937, 59.922958, 0.0], [10.730863, 59.924971, 0.0], [10.73081, 59.925535, 0.0], [10.73458, 59.932399, 0.0], [10.735622, 59.9318, 0.0], [10.736384, 59.930083, 0.0], [10.738786, 59.928577, 0.0], [10.738985, 59.927485, 0.0], [10.738614, 59.926706, 0.0], [10.738519, 59.925665, 0.0], [10.738733, 59.925293, 0.0], [10.739764, 59.924832, 0.0], [10.740039, 59.923569, 0.0], [10.741157, 59.92237, 0.0], [10.738085, 59.921361, 0.0]]]]}}, {"type": "Feature", "properties": {"name": "Ila", "id": "0301040403", "bydel": 4}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[10.749071, 59.932166, 0.0], [10.74892, 59.930683, 0.0], [10.74973, 59.928171, 0.0], [10.750149, 59.925394, 0.0], [10.750897, 59.923528, 0.0], [10.75092, 59.922911, 0.0], [10.750567, 59.922261, 0.0], [10.750699, 59.921696, 0.0], [10.750353, 59.92169, 0.0], [10.749102, 59.92069, 0.0], [10.748221, 59.920343, 0.0], [10.747341, 59.920264, 0.0], [10.746833, 59.92087, 0.0], [10.746556, 59.920851, 0.0], [10.74604, 59.919734, 0.0], [10.744859, 59.919652, 0.0], [10.743698, 59.91925, 0.0], [10.743502, 59.919789, 0.0], [10.7421, 59.921057, 0.0], [10.740039, 59.923569, 0.0], [10.739764, 59.924832, 0.0], [10.738733, 59.925293, 0.0], [10.738475, 59.925794, 0.0], [10.738683, 59.926908, 0.0], [10.738985, 59.927485, 0.0], [10.743589, 59.92994, 0.0], [10.743312, 59.930173, 0.0], [10.743727, 59.930584, 0.0], [10.74645, 59.931485, 0.0], [10.747827, 59.932255, 0.0], [10.748243, 59.93212, 0.0], [10.749303, 59.932749, 0.0], [10.749071, 59.932166, 0.0]]]]}}, {"type": "Feature", "properties": {"name": "Fagerborg", "id": "0301040404", "bydel": 4}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[10.731508, 59.926872, 0.0], [10.730681, 59.925602, 0.0], [10.729285, 59.926637, 0.0], [10.728229, 59.927899, 0.0], [10.722891, 59.931734, 0.0], [10.729243, 59.93482, 0.0], [10.732877, 59.933437, 0.0], [10.73458, 59.932399, 0.0], [10.731508, 59.926872, 0.0]]]]}}, {"type": "Feature", "properties": {"name": "Lindern", "id": "0301040405", "bydel": 4}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[10.743604, 59.941106, 0.0], [10.746619, 59.940264, 0.0], [10.746928, 59.941248, 0.0], [10.750073, 59.940812, 0.0], [10.750186, 59.940037, 0.0], [10.749554, 59.933207, 0.0], [10.749303, 59.932749, 0.0], [10.748243, 59.93212, 0.0], [10.747827, 59.932255, 0.0], [10.74645, 59.931485, 0.0], [10.743727, 59.930584, 0.0], [10.743312, 59.930173, 0.0], [10.743589, 59.92994, 0.0], [10.739149, 59.92752, 0.0], [10.738985, 59.927485, 0.0], [10.738786, 59.928577, 0.0], [10.736384, 59.930083, 0.0], [10.735497, 59.931898, 0.0], [10.732493, 59.933639, 0.0], [10.731349, 59.933892, 0.0], [10.729343, 59.934801, 0.0], [10.722891, 59.931734, 0.0], [10.715546, 59.935005, 0.0], [10.718942, 59.93687, 0.0], [10.719483, 59.936652, 0.0], [10.722817, 59.937122, 0.0], [10.724653, 59.936284, 0.0], [10.730133, 59.938181, 0.0], [10.730325, 59.93777, 0.0], [10.731186, 59.937924, 0.0], [10.731779, 59.938774, 0.0], [10.740697, 59.942985, 0.0], [10.741263, 59.942868, 0.0], [10.741994, 59.94221, 0.0], [10.743998, 59.941788, 0.0], [10.743432, 59.941157, 0.0], [10.743604, 59.941106, 0.0]]]]}}]}
--------------------------------------------------------------------------------
/src/config/topics/eierform.js:
--------------------------------------------------------------------------------
1 | import { baseUrl, apiUrl } from '../../util/config';
2 | import source from './dataSources';
3 |
4 | const API = `${apiUrl}/api/dataset`;
5 |
6 | export default {
7 | text: 'Eierform',
8 | value: 'eierform',
9 | cards: [
10 | {
11 | size: 'large',
12 | heading: 'Eierform',
13 | about: {
14 | info: 'Statistikken er basert på SSB sin husholdningsstatistikk per 1.1. Eierstatus viser husholdningens eierforhold til boligen. Som eiere av boliger regnes både selveiere og eiere gjennom borettslag eller boligaksjeselskap. Husholdningen eier boligen dersom minst en av personene i husholdningen står som eier av boligen. Når ingen av de bosatte står som eier, regnes husholdningen å ha et leieforhold til boligen.',
15 | sources: [source.ssb],
16 | externalInfo:
17 | 'https://statistikkbanken.oslo.kommune.no/statbank/pxweb/no/db1/db1__Husholdninger%20og%20boforhold/OK-BOF001.px',
18 | },
19 | map: {
20 | labels: ['Færre', 'Flere'],
21 | url: `${API}/eieform-status`,
22 | heading: 'Leietakere',
23 | method: 'ratio',
24 | },
25 | tabs: [
26 | {
27 | label: 'Andel',
28 | id: 'eieform_status_antall',
29 | heading: 'Husholdning etter eie-/leieforhold',
30 | template: 'bars',
31 | method: 'ratio',
32 | url: `${API}/eieform-status`,
33 | },
34 | {
35 | label: 'Matrise',
36 | id: 'eieform_status_andel',
37 | heading: 'Husholdning etter eie-/leieforhold',
38 | template: 'ternaryPlot',
39 | method: 'ratio',
40 | url: `${API}/eieform-status`,
41 | },
42 | {
43 | label: 'Historisk',
44 | id: 'eieform_historisk_andel',
45 | heading: 'Husholdning etter eie-/leieforhold',
46 | template: 'linesMulti',
47 | method: 'ratio',
48 | url: `${API}/eieform-historisk`,
49 | },
50 | ],
51 | },
52 | {
53 | size: 'large',
54 | heading: 'Kommunale boliger',
55 | about: {
56 | info: 'Tabellen omfatter kommunale boliger i Oslo per 1.1. Omsorg pluss boliger er med, men ikke presteboliger. Oslo kommune er selv eier av de fleste kommunale boliger.
I sameier, borettslag og aksjeselskap eier Oslo kommune en eller flere boliger, mens i kommunale eiendommer eier Oslo kommune både bygning og alle boligene.',
57 | sources: [source.ssb, source.oslo],
58 | externalInfo:
59 | 'https://statistikkbanken.oslo.kommune.no/statbank/pxweb/no/db1/db1__Boliger%20og%20byggevirksomhet__Kommunale%20boliger/',
60 | },
61 | map: {
62 | labels: ['Færre', 'Flere'],
63 | url: `${API}/kommunale-boliger-av-boligmassen-i-alt-status`,
64 | method: 'ratio',
65 | heading: 'Kommunale boliger',
66 | reverse: true,
67 | },
68 | tabs: [
69 | {
70 | label: 'Andel',
71 | id: 'kommunelage_boliger_status_andel',
72 | heading: 'Kommunale boliger',
73 | template: 'bars',
74 | method: 'ratio',
75 | url: `${API}/kommunale-boliger-av-boligmassen-i-alt-status`,
76 | },
77 | {
78 | label: 'Antall',
79 | id: 'kommunelage_boliger_status_antall',
80 | heading: 'Kommunale boliger',
81 | template: 'bars',
82 | method: 'value',
83 | url: `${API}/kommunale-boliger-av-boligmassen-i-alt-status`,
84 | },
85 | {
86 | label: 'Historisk (andel)',
87 | id: 'kommunelage_boliger_historisk_andel',
88 | heading: 'Kommunale boliger',
89 | template: 'lines',
90 | method: 'ratio',
91 | url: `${API}/kommunale-boliger-av-boligmassen-i-alt-historisk`,
92 | },
93 | {
94 | label: 'Historisk (antall)',
95 | id: 'kommunelage_boliger_historisk_antall',
96 | heading: 'Kommunale boliger',
97 | template: 'lines',
98 | method: 'value',
99 | url: `${API}/kommunale-boliger-av-boligmassen-i-alt-historisk`,
100 | },
101 | ],
102 | },
103 | ],
104 | options: {
105 | kategori: 'Boforhold',
106 | tema: 'Eierform',
107 | bgImage: `${baseUrl}/img/eieform`,
108 | txtColor: 'rgb(199, 247, 201)',
109 | },
110 | related: ['boligpriser', 'rom-per-person', 'bygningstyper'],
111 | };
112 |
--------------------------------------------------------------------------------
/src/components/OsloLogo.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
13 |
17 |
21 |
25 |
29 |
30 |
31 |
32 |
37 |
--------------------------------------------------------------------------------