forked from MapComplete/MapComplete
Refactoring: port statistics view
This commit is contained in:
parent
78c56f6fa2
commit
fcc49766d4
8 changed files with 103 additions and 169 deletions
|
@ -40,8 +40,17 @@ export default class FilteredLayer {
|
||||||
) {
|
) {
|
||||||
this.layerDef = layer
|
this.layerDef = layer
|
||||||
this.isDisplayed = isDisplayed ?? new UIEventSource(true)
|
this.isDisplayed = isDisplayed ?? new UIEventSource(true)
|
||||||
this.appliedFilters =
|
if (!appliedFilters) {
|
||||||
appliedFilters ?? new Map<string, UIEventSource<number | string | undefined>>()
|
const appliedFiltersWritable = new Map<
|
||||||
|
string,
|
||||||
|
UIEventSource<number | string | undefined>
|
||||||
|
>()
|
||||||
|
for (const filter of this.layerDef.filters) {
|
||||||
|
appliedFiltersWritable.set(filter.id, new UIEventSource(undefined))
|
||||||
|
}
|
||||||
|
appliedFilters = appliedFiltersWritable
|
||||||
|
}
|
||||||
|
this.appliedFilters = appliedFilters
|
||||||
|
|
||||||
const self = this
|
const self = this
|
||||||
const currentTags = new UIEventSource<TagsFilter>(undefined)
|
const currentTags = new UIEventSource<TagsFilter>(undefined)
|
||||||
|
@ -63,16 +72,6 @@ export default class FilteredLayer {
|
||||||
return JSON.stringify(values)
|
return JSON.stringify(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static stringToFieldProperties(value: string): Record<string, string> {
|
|
||||||
const values = JSON.parse(value)
|
|
||||||
for (const key in values) {
|
|
||||||
if (values[key] === "") {
|
|
||||||
delete values[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a FilteredLayer which is tied into the QueryParameters and/or user preferences
|
* Creates a FilteredLayer which is tied into the QueryParameters and/or user preferences
|
||||||
*/
|
*/
|
||||||
|
@ -114,6 +113,16 @@ export default class FilteredLayer {
|
||||||
return new FilteredLayer(layer, appliedFilters, isDisplayed)
|
return new FilteredLayer(layer, appliedFilters, isDisplayed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static stringToFieldProperties(value: string): Record<string, string> {
|
||||||
|
const values = JSON.parse(value)
|
||||||
|
for (const key in values) {
|
||||||
|
if (values[key] === "") {
|
||||||
|
delete values[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
private static fieldsToTags(
|
private static fieldsToTags(
|
||||||
option: FilterConfigOption,
|
option: FilterConfigOption,
|
||||||
fieldstate: string | Record<string, string>
|
fieldstate: string | Record<string, string>
|
||||||
|
@ -170,6 +179,36 @@ export default class FilteredLayer {
|
||||||
this.appliedFilters.forEach((value) => value.setData(undefined))
|
this.appliedFilters.forEach((value) => value.setData(undefined))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given tags match the current filters (and the specified 'global filters')
|
||||||
|
*/
|
||||||
|
public isShown(properties: Record<string, string>, globalFilters?: GlobalFilter[]): boolean {
|
||||||
|
if (properties._deleted === "yes") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const isShown: TagsFilter = this.layerDef.isShown
|
||||||
|
if (isShown !== undefined && !isShown.matchesProperties(properties)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let neededTags: TagsFilter = this.currentFilter.data
|
||||||
|
if (neededTags !== undefined && !neededTags.matchesProperties(properties)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const globalFilter of globalFilters ?? []) {
|
||||||
|
const neededTags = globalFilter.osmTags
|
||||||
|
if (neededTags !== undefined && !neededTags.matchesProperties(properties)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private calculateCurrentTags(): TagsFilter {
|
private calculateCurrentTags(): TagsFilter {
|
||||||
let needed: TagsFilter[] = []
|
let needed: TagsFilter[] = []
|
||||||
for (const filter of this.layerDef.filters) {
|
for (const filter of this.layerDef.filters) {
|
||||||
|
@ -209,34 +248,4 @@ export default class FilteredLayer {
|
||||||
}
|
}
|
||||||
return optimized
|
return optimized
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the given tags match the current filters (and the specified 'global filters')
|
|
||||||
*/
|
|
||||||
public isShown(properties: Record<string, string>, globalFilters?: GlobalFilter[]): boolean {
|
|
||||||
if (properties._deleted === "yes") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
{
|
|
||||||
const isShown: TagsFilter = this.layerDef.isShown
|
|
||||||
if (isShown !== undefined && !isShown.matchesProperties(properties)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let neededTags: TagsFilter = this.currentFilter.data
|
|
||||||
if (neededTags !== undefined && !neededTags.matchesProperties(properties)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const globalFilter of globalFilters ?? []) {
|
|
||||||
const neededTags = globalFilter.osmTags
|
|
||||||
if (neededTags !== undefined && !neededTags.matchesProperties(properties)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,14 +10,14 @@ import type { Writable } from "svelte/store";
|
||||||
import If from "../Base/If.svelte";
|
import If from "../Base/If.svelte";
|
||||||
import Dropdown from "../Base/Dropdown.svelte";
|
import Dropdown from "../Base/Dropdown.svelte";
|
||||||
import { onDestroy } from "svelte";
|
import { onDestroy } from "svelte";
|
||||||
import { UIEventSource } from "../../Logic/UIEventSource";
|
import { ImmutableStore, Store } from "../../Logic/UIEventSource";
|
||||||
import FilterviewWithFields from "./FilterviewWithFields.svelte";
|
import FilterviewWithFields from "./FilterviewWithFields.svelte";
|
||||||
import Tr from "../Base/Tr.svelte";
|
import Tr from "../Base/Tr.svelte";
|
||||||
import Translations from "../i18n/Translations";
|
import Translations from "../i18n/Translations";
|
||||||
|
|
||||||
export let filteredLayer: FilteredLayer;
|
export let filteredLayer: FilteredLayer;
|
||||||
export let highlightedLayer: UIEventSource<string> | undefined;
|
export let highlightedLayer: Store<string | undefined> = new ImmutableStore(undefined);
|
||||||
export let zoomlevel: UIEventSource<number>;
|
export let zoomlevel: Store<number> = new ImmutableStore(22);
|
||||||
let layer: LayerConfig = filteredLayer.layerDef;
|
let layer: LayerConfig = filteredLayer.layerDef;
|
||||||
let isDisplayed: boolean = filteredLayer.isDisplayed.data;
|
let isDisplayed: boolean = filteredLayer.isDisplayed.data;
|
||||||
onDestroy(filteredLayer.isDisplayed.addCallbackAndRunD(d => {
|
onDestroy(filteredLayer.isDisplayed.addCallbackAndRunD(d => {
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
let fieldValues: Record<string, UIEventSource<string>> = {};
|
let fieldValues: Record<string, UIEventSource<string>> = {};
|
||||||
let fieldTypes: Record<string, string> = {};
|
let fieldTypes: Record<string, string> = {};
|
||||||
let appliedFilter = <UIEventSource<string>>filteredLayer.appliedFilters.get(id);
|
let appliedFilter = <UIEventSource<string>>filteredLayer.appliedFilters.get(id);
|
||||||
let initialState: Record<string, string> = JSON.parse(appliedFilter.data ?? "{}");
|
let initialState: Record<string, string> = JSON.parse(appliedFilter?.data ?? "{}");
|
||||||
|
|
||||||
function setFields() {
|
function setFields() {
|
||||||
const properties: Record<string, string> = {};
|
const properties: Record<string, string> = {};
|
||||||
|
@ -30,7 +30,7 @@
|
||||||
properties[k] = v;
|
properties[k] = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
appliedFilter.setData(FilteredLayer.fieldsToString(properties));
|
appliedFilter?.setData(FilteredLayer.fieldsToString(properties));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const field of option.fields) {
|
for (const field of option.fields) {
|
||||||
|
@ -38,7 +38,7 @@
|
||||||
fieldTypes[field.name + "}"] = field.type;
|
fieldTypes[field.name + "}"] = field.type;
|
||||||
const src = new UIEventSource<string>(initialState[field.name] ?? "");
|
const src = new UIEventSource<string>(initialState[field.name] ?? "");
|
||||||
fieldValues[field.name + "}"] = src;
|
fieldValues[field.name + "}"] = src;
|
||||||
onDestroy(src.addCallback(() => {
|
onDestroy(src.stabilized(200).addCallback(() => {
|
||||||
setFields();
|
setFields();
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,13 @@ export class StackedRenderingChart extends ChartJs {
|
||||||
groupToOtherCutoff: options?.groupToOtherCutoff,
|
groupToOtherCutoff: options?.groupToOtherCutoff,
|
||||||
})
|
})
|
||||||
if (labels === undefined || data === undefined) {
|
if (labels === undefined || data === undefined) {
|
||||||
console.error("Could not extract data and labels for ", tr, " with features", features)
|
console.error(
|
||||||
|
"Could not extract data and labels for ",
|
||||||
|
tr,
|
||||||
|
" with features",
|
||||||
|
features,
|
||||||
|
": no labels or no data"
|
||||||
|
)
|
||||||
throw "No labels or data given..."
|
throw "No labels or data given..."
|
||||||
}
|
}
|
||||||
// labels: ["cyclofix", "buurtnatuur", ...]; data : [ ["cyclofix-changeset", "cyclofix-changeset", ...], ["buurtnatuur-cs", "buurtnatuur-cs"], ... ]
|
// labels: ["cyclofix", "buurtnatuur", ...]; data : [ ["cyclofix-changeset", "cyclofix-changeset", ...], ["buurtnatuur-cs", "buurtnatuur-cs"], ... ]
|
||||||
|
|
|
@ -7,24 +7,25 @@ import Loading from "./Base/Loading"
|
||||||
import { Utils } from "../Utils"
|
import { Utils } from "../Utils"
|
||||||
import Combine from "./Base/Combine"
|
import Combine from "./Base/Combine"
|
||||||
import { StackedRenderingChart } from "./BigComponents/TagRenderingChart"
|
import { StackedRenderingChart } from "./BigComponents/TagRenderingChart"
|
||||||
import { LayerFilterPanel } from "./BigComponents/FilterView"
|
|
||||||
import MapState from "../Logic/State/MapState"
|
|
||||||
import BaseUIElement from "./BaseUIElement"
|
import BaseUIElement from "./BaseUIElement"
|
||||||
import Title from "./Base/Title"
|
import Title from "./Base/Title"
|
||||||
import { FixedUiElement } from "./Base/FixedUiElement"
|
import { FixedUiElement } from "./Base/FixedUiElement"
|
||||||
import List from "./Base/List"
|
import List from "./Base/List"
|
||||||
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
|
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
|
||||||
import mcChanges from "../assets/generated/themes/mapcomplete-changes.json"
|
import mcChanges from "../assets/generated/themes/mapcomplete-changes.json"
|
||||||
|
import SvelteUIElement from "./Base/SvelteUIElement"
|
||||||
|
import Filterview from "./BigComponents/Filterview.svelte"
|
||||||
|
import FilteredLayer from "../Models/FilteredLayer"
|
||||||
|
|
||||||
class StatisticsForOverviewFile extends Combine {
|
class StatisticsForOverviewFile extends Combine {
|
||||||
constructor(homeUrl: string, paths: string[]) {
|
constructor(homeUrl: string, paths: string[]) {
|
||||||
paths = paths.filter((p) => !p.endsWith("file-overview.json"))
|
paths = paths.filter((p) => !p.endsWith("file-overview.json"))
|
||||||
const layer = new LayoutConfig(<any>mcChanges, true).layers[0]
|
const layer = new LayoutConfig(<any>mcChanges, true).layers[0]
|
||||||
const filteredLayer = MapState.InitializeFilteredLayers(
|
const filteredLayer = new FilteredLayer(layer)
|
||||||
{ id: "statistics-view", layers: [layer] },
|
const filterPanel = new Combine([
|
||||||
undefined
|
new Title("Filters"),
|
||||||
)[0]
|
new SvelteUIElement(Filterview, { filteredLayer }),
|
||||||
const filterPanel = new LayerFilterPanel(undefined, filteredLayer)
|
])
|
||||||
const appliedFilters = filteredLayer.appliedFilters
|
|
||||||
|
|
||||||
const downloaded = new UIEventSource<{ features: ChangeSetData[] }[]>([])
|
const downloaded = new UIEventSource<{ features: ChangeSetData[] }[]>([])
|
||||||
|
|
||||||
|
@ -63,20 +64,10 @@ class StatisticsForOverviewFile extends Combine {
|
||||||
return loading
|
return loading
|
||||||
}
|
}
|
||||||
|
|
||||||
let overview = ChangesetsOverview.fromDirtyData(
|
const overview = ChangesetsOverview.fromDirtyData(
|
||||||
[].concat(...downloaded.map((d) => d.features))
|
[].concat(...downloaded.map((d) => d.features))
|
||||||
)
|
).filter((cs) => filteredLayer.isShown(<any>cs.properties))
|
||||||
if (appliedFilters.data.size > 0) {
|
console.log("Overview is", overview)
|
||||||
appliedFilters.data.forEach((filterSpec) => {
|
|
||||||
const tf = filterSpec?.currentFilter
|
|
||||||
if (tf === undefined) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
overview = overview.filter((cs) =>
|
|
||||||
tf.matchesProperties(cs.properties)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overview._meta.length === 0) {
|
if (overview._meta.length === 0) {
|
||||||
return "No data matched the filter"
|
return "No data matched the filter"
|
||||||
|
@ -143,6 +134,10 @@ class StatisticsForOverviewFile extends Combine {
|
||||||
new Title("Breakdown"),
|
new Title("Breakdown"),
|
||||||
]
|
]
|
||||||
for (const tr of trs) {
|
for (const tr of trs) {
|
||||||
|
if (tr.question === undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
console.log(tr)
|
||||||
let total = undefined
|
let total = undefined
|
||||||
if (tr.freeform?.key !== undefined) {
|
if (tr.freeform?.key !== undefined) {
|
||||||
total = new Set(
|
total = new Set(
|
||||||
|
@ -174,7 +169,7 @@ class StatisticsForOverviewFile extends Combine {
|
||||||
|
|
||||||
return new Combine(elements)
|
return new Combine(elements)
|
||||||
},
|
},
|
||||||
[appliedFilters]
|
[filteredLayer.currentFilter]
|
||||||
)
|
)
|
||||||
).SetClass("block w-full h-full"),
|
).SetClass("block w-full h-full"),
|
||||||
])
|
])
|
||||||
|
@ -232,30 +227,12 @@ class ChangesetsOverview {
|
||||||
}
|
}
|
||||||
public readonly _meta: ChangeSetData[]
|
public readonly _meta: ChangeSetData[]
|
||||||
|
|
||||||
public static fromDirtyData(meta: ChangeSetData[]) {
|
|
||||||
return new ChangesetsOverview(meta?.map((cs) => ChangesetsOverview.cleanChangesetData(cs)))
|
|
||||||
}
|
|
||||||
|
|
||||||
private constructor(meta: ChangeSetData[]) {
|
private constructor(meta: ChangeSetData[]) {
|
||||||
this._meta = Utils.NoNull(meta)
|
this._meta = Utils.NoNull(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
public filter(predicate: (cs: ChangeSetData) => boolean) {
|
public static fromDirtyData(meta: ChangeSetData[]) {
|
||||||
return new ChangesetsOverview(this._meta.filter(predicate))
|
return new ChangesetsOverview(meta?.map((cs) => ChangesetsOverview.cleanChangesetData(cs)))
|
||||||
}
|
|
||||||
|
|
||||||
public sum(key: string, excludeThemes: Set<string>): number {
|
|
||||||
let s = 0
|
|
||||||
for (const feature of this._meta) {
|
|
||||||
if (excludeThemes.has(feature.properties.theme)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const parsed = Number(feature.properties[key])
|
|
||||||
if (!isNaN(parsed)) {
|
|
||||||
s += parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static cleanChangesetData(cs: ChangeSetData): ChangeSetData {
|
private static cleanChangesetData(cs: ChangeSetData): ChangeSetData {
|
||||||
|
@ -286,6 +263,24 @@ class ChangesetsOverview {
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return cs
|
return cs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public filter(predicate: (cs: ChangeSetData) => boolean) {
|
||||||
|
return new ChangesetsOverview(this._meta.filter(predicate))
|
||||||
|
}
|
||||||
|
|
||||||
|
public sum(key: string, excludeThemes: Set<string>): number {
|
||||||
|
let s = 0
|
||||||
|
for (const feature of this._meta) {
|
||||||
|
if (excludeThemes.has(feature.properties.theme)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const parsed = Number(feature.properties[key])
|
||||||
|
if (!isNaN(parsed)) {
|
||||||
|
s += parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChangeSetData {
|
interface ChangeSetData {
|
||||||
|
@ -323,3 +318,5 @@ interface ChangeSetData {
|
||||||
language: string
|
language: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
new StatisticsGUI().AttachTo("main")
|
||||||
|
|
|
@ -1,74 +0,0 @@
|
||||||
|
|
||||||
.tabs-header-bar {
|
|
||||||
padding-left: 1em;
|
|
||||||
padding-top: 10px; /* For the shadow */
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: start;
|
|
||||||
background-color: var(--background-color);
|
|
||||||
max-width: 100%;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.tab-single-header img {
|
|
||||||
height: 3em;
|
|
||||||
max-width: 3em;
|
|
||||||
padding: 0.5em;
|
|
||||||
display: block;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-single-header svg {
|
|
||||||
height: 3em;
|
|
||||||
max-width: 3em;
|
|
||||||
padding: 0.5em;
|
|
||||||
display: block;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.tab-single-header {
|
|
||||||
border-top-left-radius: 1em;
|
|
||||||
border-top-right-radius: 1em;
|
|
||||||
z-index: 5000;
|
|
||||||
padding-bottom: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-active {
|
|
||||||
background-color: var(--background-color);
|
|
||||||
color: var(--foreground-color);
|
|
||||||
z-index: 5001;
|
|
||||||
box-shadow: 0 0 10px var(--shadow-color);
|
|
||||||
border: 1px solid var(--background-color);
|
|
||||||
min-width: 4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-active svg {
|
|
||||||
fill: var(--foreground-color);
|
|
||||||
stroke: var(--foreground-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-non-active {
|
|
||||||
background-color: var(--subtle-detail-color);
|
|
||||||
color: var(--foreground-color);
|
|
||||||
opacity: 0.5;
|
|
||||||
border-left: 1px solid gray;
|
|
||||||
border-right: 1px solid gray;
|
|
||||||
border-top: 1px solid gray;
|
|
||||||
border-bottom: 1px solid lightgray;
|
|
||||||
min-width: 4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-non-active svg {
|
|
||||||
fill: var(--non-active-tab-svg) !important;
|
|
||||||
stroke: var(--non-active-tab-svg) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-non-active svg path {
|
|
||||||
fill: var(--non-active-tab-svg) !important;
|
|
||||||
stroke: var(--non-active-tab-svg) !important;
|
|
||||||
}
|
|
|
@ -3,8 +3,6 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta content="width=device-width, initial-scale=1.0, user-scalable=no" name="viewport">
|
<meta content="width=device-width, initial-scale=1.0, user-scalable=no" name="viewport">
|
||||||
<link href="./vendor/leaflet.css" rel="stylesheet"/>
|
|
||||||
<link href="./css/tabbedComponent.css" rel="stylesheet"/>
|
|
||||||
<link href="./css/mobile.css" rel="stylesheet"/>
|
<link href="./css/mobile.css" rel="stylesheet"/>
|
||||||
<link href="./css/openinghourstable.css" rel="stylesheet"/>
|
<link href="./css/openinghourstable.css" rel="stylesheet"/>
|
||||||
<link href="./css/tagrendering.css" rel="stylesheet"/>
|
<link href="./css/tagrendering.css" rel="stylesheet"/>
|
||||||
|
|
|
@ -4,8 +4,6 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta content="width=device-width, initial-scale=1.0, user-scalable=no" name="viewport">
|
<meta content="width=device-width, initial-scale=1.0, user-scalable=no" name="viewport">
|
||||||
<link href="./css/userbadge.css" rel="stylesheet"/>
|
|
||||||
<link href="./css/tabbedComponent.css" rel="stylesheet"/>
|
|
||||||
<link href="./css/mobile.css" rel="stylesheet"/>
|
<link href="./css/mobile.css" rel="stylesheet"/>
|
||||||
<link href="./css/openinghourstable.css" rel="stylesheet"/>
|
<link href="./css/openinghourstable.css" rel="stylesheet"/>
|
||||||
<link href="./css/tagrendering.css" rel="stylesheet"/>
|
<link href="./css/tagrendering.css" rel="stylesheet"/>
|
||||||
|
|
Loading…
Reference in a new issue