diff --git a/packages/gui/src/components/crime-script-page.ts b/packages/gui/src/components/crime-script-page.ts index 45c615b..b66429e 100644 --- a/packages/gui/src/components/crime-script-page.ts +++ b/packages/gui/src/components/crime-script-page.ts @@ -31,6 +31,8 @@ export const CrimeScriptPage: MeiosisComponent = () => { geoLocations = [], transports = [], products = [], + partners = [], + serviceProviders = [], } = model; id = m.route.param('id') || currentCrimeScriptId || (crimeScripts.length > 0 ? crimeScripts[0].id : ''); const crimeScript = @@ -97,6 +99,8 @@ export const CrimeScriptPage: MeiosisComponent = () => { geoLocations, transports, products, + partners, + serviceProviders, }) : m(CrimeScriptViewer, { crimeScript, @@ -106,6 +110,8 @@ export const CrimeScriptPage: MeiosisComponent = () => { locations, geoLocations, products, + partners, + serviceProviders, curActIdx, curPhaseIdx, update: actions.update, diff --git a/packages/gui/src/components/layout.ts b/packages/gui/src/components/layout.ts index 516358f..0b7fba2 100644 --- a/packages/gui/src/components/layout.ts +++ b/packages/gui/src/components/layout.ts @@ -192,20 +192,31 @@ export const Layout: MeiosisComponent = () => { ]), ] ), - m('.container', { style: 'padding-top: 5px' }, children), m( - 'a', - { - href: 'https://www.tno.nl', - target: '_blank', - style: { - position: 'fixed', - bottom: '0', - right: '10px', - }, - }, - m('img[width=100][height=50][alt=TNO website][title=TNO website].right', { src: tno }) + '.container', + { style: 'padding-top: 5px' }, + children, + m( + '.row', + m( + '.col.s12', + m( + 'a', + { + href: 'https://www.tno.nl', + target: '_blank', + // style: { + // position: 'fixed', + // bottom: '0', + // right: '10px', + // }, + }, + m('img[width=100][height=50][alt=TNO website][title=TNO website].right', { src: tno }) + ) + ) + ) ), + , ]), ]; }, diff --git a/packages/gui/src/components/settings-page.ts b/packages/gui/src/components/settings-page.ts index a6d35f0..2fd7c01 100644 --- a/packages/gui/src/components/settings-page.ts +++ b/packages/gui/src/components/settings-page.ts @@ -15,6 +15,7 @@ import { deepCopy, FormAttributes, LayoutForm } from 'mithril-ui-form'; import { Collapsible, FlatButton, Tabs } from 'mithril-materialized'; import { attrForm, AttributeType } from '../models/forms'; import { TextInputWithClear } from './ui/text-input-with-clear'; +import { sortByLabel } from '../utils'; export const SettingsPage: MeiosisComponent = () => { let edit = false; @@ -47,10 +48,8 @@ export const SettingsPage: MeiosisComponent = () => { transports = [], locations = [], geoLocations = [], - opportunities = [], - indicators = [], - barriers = [], partners = [], + serviceProviders = [], } = model; const labelFilter = attributeFilter ? attributeFilter.toLowerCase() : undefined; @@ -65,6 +64,13 @@ export const SettingsPage: MeiosisComponent = () => { 'person', cast.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), ], + [ + 'serviceProviders', + t('SERVICE_PROVIDERS'), + 'serviceProviders', + 'business', + serviceProviders.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), + ], [ 'attributes', t('ATTRIBUTES'), @@ -100,20 +106,6 @@ export const SettingsPage: MeiosisComponent = () => { 'location_on', geoLocations.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), ], - [ - 'opportunities', - t('OPPORTUNITIES'), - 'opportunities', - 'lightbulb', - opportunities.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), - ], - [ - 'indicators', - t('INDICATORS'), - 'indicators', - 'light_mode', - indicators.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), - ], [ 'partners', t('PARTNERS'), @@ -121,13 +113,6 @@ export const SettingsPage: MeiosisComponent = () => { 'handshake', // groups partners.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), ], - [ - 'barriers', - t('BARRIERS'), - 'barriers', - 'block', - barriers.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), - ], ] as Array< [id: AttributeType, label: string, type: AttributeType, iconName: string, attrs: Array] >; @@ -156,6 +141,14 @@ export const SettingsPage: MeiosisComponent = () => { if (edit) { storedModel = deepCopy(model); } else { + model.cast?.sort(sortByLabel); + model.serviceProviders?.sort(sortByLabel); + model.attributes?.sort(sortByLabel); + model.products?.sort(sortByLabel); + model.transports?.sort(sortByLabel); + model.locations?.sort(sortByLabel); + model.geoLocations?.sort(sortByLabel); + model.partners?.sort(sortByLabel); actions.saveModel(model); } }, @@ -172,14 +165,14 @@ export const SettingsPage: MeiosisComponent = () => { }), ], m(Tabs, { - tabWidth: 'fixed', + tabWidth: 'auto', tabs: tabs.map(([id, label, type, iconName, attr]) => { return { id: label.replace('è', 'e'), title: `${attr.length ? `${attr.length} ` : ''}${label}`, vnode: edit ? m(LayoutForm, { - form: attrForm(id, label, type === 'barriers' ? partners : attr, type), + form: attrForm(id, label, attr, type), obj: model, } as FormAttributes) : m(AttrView, { @@ -215,7 +208,7 @@ const AttrView: FactoryComponent<{ .sort((a, b) => a.label?.localeCompare(b.label)) .map((c) => { const searchResults = crimeScripts.reduce((acc, cs, crimeScriptIdx) => { - cs.stages?.forEach(({ ids }) => { + cs.stages?.forEach(({ ids = [] }) => { ids.forEach((actId) => { const actIdx = acts.findIndex((a) => a.id === actId); if (actIdx < 0) return; diff --git a/packages/gui/src/components/ui/crime-script-editor.ts b/packages/gui/src/components/ui/crime-script-editor.ts index 3b287b9..9e8f7d1 100644 --- a/packages/gui/src/components/ui/crime-script-editor.ts +++ b/packages/gui/src/components/ui/crime-script-editor.ts @@ -4,7 +4,6 @@ import { Activity, ActivityPhase, Cast, - Condition, CrimeScript, CrimeScriptAttributes, ID, @@ -16,6 +15,10 @@ import { GeographicLocation, ActivityType, Product, + Opportunity, + Indicator, + Partner, + ServiceProvider, } from '../../models'; import { FlatButton, Tabs, uniqueId, Select, ISelectOptions, SearchSelect } from 'mithril-materialized'; import { FormAttributes, LayoutForm, UIForm } from 'mithril-ui-form'; @@ -33,6 +36,8 @@ export const CrimeScriptEditor: FactoryComponent<{ locations: CrimeLocation[]; geoLocations: GeographicLocation[]; products: Product[]; + partners: Partner[]; + serviceProviders: ServiceProvider[]; }> = () => { const actsForm: UIForm = [ { @@ -48,22 +53,36 @@ export const CrimeScriptEditor: FactoryComponent<{ let geoLocationOptions: InputOptions[] = []; let transportOptions: InputOptions[] = []; let castOptions: InputOptions[] = []; + let serviceProviderOptions: InputOptions[] = []; let attrOptions: InputOptions[] = []; let productOptions: InputOptions[] = []; let measuresForm: UIForm = []; let activityForm: UIForm; + let opportunitiesForm: UIForm; + let indicatorsForm: UIForm; const ActivityTypeOptions = [ // { id: ActivityType.NONE, label: 'None' }, { id: ActivityType.HAS_CAST, label: t('CAST') }, - { id: ActivityType.HAS_ATTRIBUTES, label: t('ATTRIBUTES') }, - { id: ActivityType.HAS_TRANSPORT, label: t('TRANSPORTS') }, - // { id: ActivityType.HAS_CAST_ATTRIBUTES, label: 'Both' }, + { id: ActivityType.HAS_ATTRIBUTES, label: t('ATTRIBUTE') }, + { id: ActivityType.HAS_TRANSPORT, label: t('TRANSPORT') }, + { id: ActivityType.HAS_SERVICE_PROVIDER, label: t('SERVICE_PROVIDER') }, ]; + const measOptions = crimeMeasureOptions(); + return { oninit: ({ - attrs: { cast = [], attributes = [], locations = [], geoLocations = [], transports = [], products = [] }, + attrs: { + cast = [], + attributes = [], + locations = [], + geoLocations = [], + transports = [], + products = [], + partners = [], + serviceProviders = [], + }, }) => { castOptions = toOptions(cast, true); attrOptions = toOptions(attributes); @@ -71,6 +90,7 @@ export const CrimeScriptEditor: FactoryComponent<{ geoLocationOptions = toOptions(geoLocations); transportOptions = toOptions(transports); productOptions = toOptions(products); + serviceProviderOptions = toOptions(serviceProviders); activityForm = [ { @@ -86,20 +106,23 @@ export const CrimeScriptEditor: FactoryComponent<{ repeat: true, type: [ { id: 'id', type: 'autogenerate', autogenerate: 'id' }, - { id: 'label', type: 'textarea', className: 'col s8 m9 xl10', label: t('ACTIVITY') }, + { id: 'label', type: 'textarea', className: 'col s8 m10', label: t('ACTIVITY') }, + { id: 'header', type: 'switch', className: 'col s4 m2', label: t('HEADER') }, { id: 'type', - type: 'options', - className: 'col s4 m3 xl2', + type: 'select', + // show: ['!header'], + multiple: true, + className: 'col s6 m4 l3', label: t('SPECIFY'), options: ActivityTypeOptions, - checkboxClass: 'col s6', + checkboxClass: 'col s4', }, { id: 'cast', show: ['type=1'], - type: 'select', - className: 'col s12 m4', + type: 'search_select', + className: 'col s6 m4 l3', multiple: true, options: castOptions, label: t('CAST'), @@ -107,8 +130,8 @@ export const CrimeScriptEditor: FactoryComponent<{ { id: 'attributes', show: ['type=2'], - type: 'select', - className: 'col s12 m4', + type: 'search_select', + className: 'col s6 m4 l3', multiple: true, options: attrOptions, label: t('ATTRIBUTES'), @@ -116,53 +139,89 @@ export const CrimeScriptEditor: FactoryComponent<{ { id: 'transports', show: ['type=4'], - type: 'select', - className: 'col s12 m4', + type: 'search_select', + className: 'col s6 m4 l3', multiple: true, options: transportOptions, label: t('TRANSPORTS'), }, { - id: 'description', - label: t('DESCRIPTION'), - type: 'textarea', + id: 'sp', + show: ['type=8'], + type: 'search_select', + className: 'col s6 m4 l3', + multiple: true, + options: serviceProviderOptions, + label: t('SERVICE_PROVIDER'), }, + // { + // id: 'description', + // label: t('DESCRIPTION'), + // type: 'textarea', + // }, ] as UIForm, className: 'col s12', label: t('ACTIVITIES'), }, + ]; + + opportunitiesForm = [ { id: 'conditions', repeat: true, type: [ { id: 'id', type: 'autogenerate', autogenerate: 'id' }, - { id: 'label', type: 'textarea', className: 'col s12', label: t('CONDITION') }, - ] as UIForm, + { id: 'label', type: 'textarea', className: 'col s12', label: t('OPPORTUNITY') }, + ] as UIForm, className: 'col s12', label: t('CONDITIONS'), }, ]; - const measOptions = crimeMeasureOptions(); + indicatorsForm = [ + { + id: 'indicators', + repeat: true, + type: [ + { id: 'id', type: 'autogenerate', autogenerate: 'id' }, + { id: 'label', type: 'textarea', className: 'col s12', label: t('INDICATOR') }, + ] as UIForm, + className: 'col s12', + label: t('INDICATORS'), + }, + ]; const measureForm: UIForm = [ { id: 'id', type: 'autogenerate', autogenerate: 'id' }, - { id: 'cat', type: 'select', options: measOptions, className: 'col s12 m5 l4', label: t('CATEGORY') }, - { id: 'label', type: 'text', className: 'col s12 m7 l8', label: t('NAME') }, + { id: 'cat', type: 'select', options: measOptions, className: 'col s6 m5 l4', label: t('CATEGORY') }, + { + id: 'partners', + type: 'select', + multiple: true, + className: 'col s6 m7 l8', + label: t('PARTNERS'), + options: partners.filter(({ label }) => label).map(({ id, label }) => ({ id, label, icon: 'handshake' })), + }, + { id: 'label', type: 'textarea', className: 'col s12', label: t('NAME') }, // { id: 'description', type: 'textarea', className: 'col s12', label: t('DESCRIPTION') }, ]; - measuresForm = [{ id: 'measures', type: measureForm, repeat: true, label: t('MEASURES') }]; + measuresForm = [{ id: 'measures', type: measureForm, repeat: true, label: t('MEASURE') }]; }, view: ({ attrs: { acts, crimeScript } }) => { const curActIdx = +(m.route.param('stages') || 1) - 1; - const curActIds = crimeScript.stages && curActIdx < crimeScript.stages.length && crimeScript.stages[curActIdx]; + const curActIds = + crimeScript.stages && curActIdx < crimeScript.stages.length + ? crimeScript.stages[curActIdx] + : ({ id: '', ids: [] } as Stage); + if (!curActIds.ids) curActIds.ids = []; const curActId = curActIds && curActIds.id; - const curAct = curActId && acts.find((a) => a.id === curActId); + const curAct = curActId ? acts.find((a) => a.id === curActId) : undefined; if (curAct) { if (!curAct.measures) curAct.measures = []; } + const key = curAct ? curAct.id : 'cur-act-id'; return m('.col.s12', [ m(LayoutForm, { form: [ @@ -191,101 +250,131 @@ export const CrimeScriptEditor: FactoryComponent<{ i18n: I18N, } as FormAttributes>), - curActIds && [ + curActIds && + curActIds.ids && [ + m( + '.row', + [ + m(SearchSelect, { + key, + label: t('SELECT_ACT_N'), + options: acts, + initialValue: curActIds.ids, + className: 'col s12 m4 l5', + onchange: (selectedIds) => { + crimeScript.stages[curActIdx] = { + id: selectedIds.length > 0 ? selectedIds[0] : '', + ids: selectedIds, + }; + // m.redraw(); + }, + }), + curActIds.ids.length > 0 + ? m(Select, { + key, + label: t('SELECT_ACT'), + className: 'col s12 m4 l5', + initialValue: curActIds.id, + // disabled: curActIds.ids.length === 1, + options: acts.filter((a) => curActIds.ids.includes(a.id)), + onchange: (id) => { + crimeScript.stages[curActIdx].id = id[0]; + }, + } as ISelectOptions) + : undefined, + m(FlatButton, { + key, + label: t('ACT'), + className: 'col s12 m4 l2', + iconName: 'add', + onclick: () => { + const id = uniqueId(); + const newAct = { + id, + label: t('ACT'), + } as Act; + acts.push(newAct); + crimeScript.stages[curActIdx].id = id; + if (crimeScript.stages[curActIdx].ids) { + crimeScript.stages[curActIdx].ids.push(id); + } else { + crimeScript.stages[curActIdx].ids = [id]; + } + }, + }), + ].filter(Boolean) + ), + ], + + curAct && m( - '.col.s12', - m(SearchSelect, { - key: curAct ? curAct.id : '', - label: t('SELECT_ACT_N'), - options: acts, - initialValue: curActIds.ids, - // max: 5, - // search: true, - // selectAll: false, - // listAll: true, - onchange: (selectedIds) => { - crimeScript.stages[curActIdx] = { - id: selectedIds.length > 0 ? selectedIds[0] : '', - ids: selectedIds, - }; - // m.redraw(); - }, - }) + '.row', + m('.col.s12', [ + m('.cur-act', { key: curAct.id }, [ + m(LayoutForm, { + form: [ + { id: 'label', type: 'text', className: 'col s6 m9', label: t('NAME'), show: ['!icon=1'] }, + { id: 'label', type: 'text', className: 'col s6 m6', label: t('NAME'), show: ['icon=1'] }, + { id: 'icon', type: 'select', className: 'col s6 m3', label: t('IMAGE'), options: IconOpts }, + { id: 'url', type: 'base64', className: 'col s12 m3', label: t('IMAGE'), show: ['icon=1'] }, + { id: 'description', type: 'textarea', className: 'col s12', label: t('SUMMARY') }, + ], + obj: curAct, + onchange: () => {}, + i18n: I18N, + } as FormAttributes>), + m(Tabs, { + tabs: [ + { + title: t('STEPS'), + vnode: m('.acts', [ + m(LayoutForm, { + form: activityForm, + obj: curAct, + onchange: () => {}, + i18n: I18N, + } as FormAttributes>), + ]), + }, + { + title: t('OPPORTUNITIES'), + vnode: m('.opportunities', [ + m(LayoutForm, { + form: opportunitiesForm, + obj: curAct, + onchange: () => {}, + i18n: I18N, + } as FormAttributes>), + ]), + }, + { + title: t('INDICATORS'), + vnode: m('.indicators', [ + m(LayoutForm, { + form: indicatorsForm, + obj: curAct, + onchange: () => {}, + i18n: I18N, + } as FormAttributes>), + ]), + }, + { + id: 'measures', + title: t('MEASURES'), + vnode: m('.measures', [ + m(LayoutForm, { + form: measuresForm, + obj: curAct, + onchange: () => {}, + i18n: I18N, + } as FormAttributes>), + ]), + }, + ], + }), + ]), + ]) ), - curActIds.ids && - curActIds.ids.length > 0 && [ - m(Select, { - key: curAct ? curAct.label : curActIds.id, - label: t('SELECT_ACT'), - className: 'col s6', - initialValue: curActIds.id, - // disabled: curActIds.ids.length === 1, - options: acts.filter((a) => curActIds.ids.includes(a.id)), - onchange: (id) => { - crimeScript.stages[curActIdx].id = id[0]; - }, - } as ISelectOptions), - ], - m(FlatButton, { - label: t('ACT'), - className: 'col s6', - iconName: 'add', - onclick: () => { - const id = uniqueId(); - const newAct = { - id, - label: t('ACT'), - } as Act; - acts.push(newAct); - crimeScript.stages[curActIdx].id = id; - if (crimeScript.stages[curActIdx].ids) { - crimeScript.stages[curActIdx].ids.push(id); - } else { - crimeScript.stages[curActIdx].ids = [id]; - } - }, - }), - ], - - curAct && [ - m('.cur-act', { key: curAct.id }, [ - m(LayoutForm, { - form: [ - { id: 'label', type: 'text', className: 'col s6 m6', label: t('NAME') }, - { id: 'icon', type: 'select', className: 'col s6 m3', label: t('IMAGE'), options: IconOpts }, - { id: 'url', type: 'base64', className: 'col s12 m3', label: t('IMAGE'), show: ['icon=1'] }, - { id: 'description', type: 'textarea', className: 'col s12', label: t('SUMMARY') }, - ], - obj: curAct, - onchange: () => {}, - i18n: I18N, - } as FormAttributes>), - m(Tabs, { - tabs: [ - { - title: t('ACT'), - vnode: m('.acts', [ - m(LayoutForm, { - form: activityForm, - obj: curAct, - onchange: () => {}, - i18n: I18N, - } as FormAttributes>), - ]), - }, - ], - }), - m('h5', t('MEASURES')), - m(LayoutForm, { - form: measuresForm, - obj: curAct, - onchange: () => { - // console.log(curAct); - }, - i18n: I18N, - } as FormAttributes>), - ]), - ], ]); }, }; diff --git a/packages/gui/src/components/ui/crime-script-viewer.ts b/packages/gui/src/components/ui/crime-script-viewer.ts index 553bd2c..0579a4c 100644 --- a/packages/gui/src/components/ui/crime-script-viewer.ts +++ b/packages/gui/src/components/ui/crime-script-viewer.ts @@ -9,18 +9,27 @@ import { ICONS, ID, IconOpts, + Labeled, + Partner, Product, - Stage, + ServiceProvider, missingIcon, } from '../../models'; import { State } from '../../services'; -import { ITabItem, Tabs, Icon } from 'mithril-materialized'; +import { ITabItem, Tabs } from 'mithril-materialized'; import { render, SlimdownView } from 'mithril-ui-form'; import { Patch } from 'meiosis-setup/types'; import { ReferenceListComponent } from '../ui/reference'; import { lookupCrimeMeasure } from '../../models/situational-crime-prevention'; import { t } from '../../services/translations'; -import { generateLabeledItemsMarkup, toCommaSeparatedList, toMarkdownOl, toMarkdownUl } from '../../utils'; +import { + generateLabeledItemsMarkup, + measuresToMarkdown, + toCommaSeparatedList, + toMarkdownOl, + toMarkdownUl, +} from '../../utils'; +import { ProcessStep, ProcessVisualization } from './process-visualisation'; export const CrimeScriptViewer: FactoryComponent<{ crimeScript: CrimeScript; @@ -30,15 +39,19 @@ export const CrimeScriptViewer: FactoryComponent<{ locations: CrimeLocation[]; geoLocations: GeographicLocation[]; products: Product[]; + partners: Partner[]; + serviceProviders: ServiceProvider[]; curActIdx?: number; curPhaseIdx?: number; update: (patch: Patch) => void; }> = () => { + const lookupPartner = new Map(); const findCrimeMeasure = lookupCrimeMeasure(); const visualizeAct = ( - { label = '...', activities = [], conditions = [], locationIds = [] } = {} as Act, + { label = '...', activities = [], indicators = [], conditions = [], locationIds = [] } = {} as Act, cast: Cast[], + serviceProviders: ServiceProvider[], attributes: CrimeScriptAttributes[], locations: CrimeLocation[], curPhaseIdx = -1 @@ -50,6 +63,12 @@ export const CrimeScriptViewer: FactoryComponent<{ return acc; }, new Set()) ); + const spIds = Array.from( + activities.reduce((acc, { sp }) => { + if (sp) sp.forEach((id) => acc.add(id)); + return acc; + }, new Set()) + ); const attrIds = Array.from( activities.reduce((acc, { attributes: curAttr }) => { if (curAttr) curAttr.forEach((id) => acc.add(id)); @@ -57,7 +76,7 @@ export const CrimeScriptViewer: FactoryComponent<{ }, new Set()) ); const md = `${ - locationIds + locationIds && locationIds.length ? `##### ${t('LOCATIONS', locationIds.length)} ${toMarkdownUl(locations, locationIds)}` @@ -75,6 +94,14 @@ ${toMarkdownOl(cast, castIds)}` : '' } +${ + spIds.length > 0 + ? `##### ${t('SERVICE_PROVIDERS')} + +${toMarkdownOl(serviceProviders, spIds)}` + : '' +} + ${ conditions.length > 0 ? `##### ${t('CONDITIONS')} @@ -83,6 +110,14 @@ ${conditions.map((cond, i) => `${i + 1}. ` + cond.label).join('\n')}` : '' } +${ + indicators.length > 0 + ? `##### ${t('INDICATORS')} + +${indicators.map((ind, i) => `${i + 1}. ` + ind.label).join('\n')}` + : '' +} + ${ attrIds.length > 0 ? `##### ${t('ATTRIBUTES')} @@ -124,63 +159,105 @@ ${toMarkdownOl(attributes, attrIds)}` attrs: { crimeScript, cast = [], + serviceProviders = [], acts = [], attributes = [], locations = [], geoLocations = [], products = [], + partners = [], curActIdx = -1, curPhaseIdx = 0, update, }, }) => { + if (lookupPartner.size < partners.length) { + partners.forEach((p) => lookupPartner.set(p.id, p)); + } const { label = '...', description, literature, stages = [], productIds = [], geoLocationIds = [] } = crimeScript; - const [allCastIds, allAttrIds, allLocIds] = stages.reduce( + const [allCastIds, allSpIds, allAttrIds, allLocIds] = stages.reduce( (acc, stage) => { const act = acts.find((a) => a.id === stage.id); if (act) { if (act.locationIds) { - act.locationIds.forEach((id) => acc[2].add(id)); + act.locationIds.forEach((id) => acc[3].add(id)); } act.activities.forEach((activity) => { activity.cast?.forEach((id) => acc[0].add(id)); - activity.attributes?.forEach((id) => acc[1].add(id)); + activity.sp?.forEach((id) => acc[1].add(id)); + activity.attributes?.forEach((id) => acc[2].add(id)); }); } return acc; }, - [new Set(), new Set(), new Set()] as [cast: Set, attr: Set, locs: Set] + [new Set(), new Set(), new Set(), new Set()] as [ + cast: Set, + sp: Set, + attr: Set, + locs: Set + ] ); - const allStages = stages.reduce((acc, cur, index) => { - cur.ids.forEach((id, idx) => { - const act = acts.find((a) => a.id === id); - if (act) { - const counter = `${index + 1}${cur.ids.length === 1 ? '' : String.fromCharCode(97 + idx)}`; - const title = `${counter}. ${act.label}`; - const selectedVariant = cur.ids.length > 1 && id === cur.id; - acc.push({ stage: cur, stageIdx: index, title, act, selectedVariant }); - } - }); - return acc; - }, [] as Array<{ stage: Stage; stageIdx: number; title: string; act: Act; selectedVariant: boolean }>); - const selectedAct = curActIdx >= 0 ? acts[curActIdx] : allStages.length > 0 ? allStages[0].act : undefined; + const selectedAct = + 0 <= curActIdx && curActIdx < acts.length + ? acts[curActIdx] + : stages.length > 0 + ? acts.find((a) => a.id === stages[0].id) + : undefined; const selectedActContent = selectedAct - ? visualizeAct(selectedAct, cast, attributes, locations, curPhaseIdx) + ? visualizeAct(selectedAct, cast, serviceProviders, attributes, locations, curPhaseIdx) : undefined; + console.log(selectedAct?.measures); const measuresMd = - selectedAct && - selectedAct.measures.length > 0 && - `##### ${t('MEASURES')} + selectedAct && selectedAct.measures?.length > 0 + ? `##### ${t('MEASURES')} + +${measuresToMarkdown(selectedAct.measures, lookupPartner, findCrimeMeasure)}` + : undefined; + // ${selectedAct.measures + // .map((measure, i) => `${i + 1}. **${findCrimeMeasure(measure.cat)?.label}:** ${measure.label}`) + // .join('\n')}`; -${selectedAct.measures - .map((measure, i) => `${i + 1}. ${findCrimeMeasure(measure.cat)?.label}: ${measure.label}`) - .join('\n')}`; + const steps = stages + .map(({ id: actId, ids }) => { + const act = acts.find((a) => a.id === actId); + if (act) { + const { id, label = '...', icon, url, description = '' } = act; + const imgSrc = (icon === ICONS.OTHER ? url : IconOpts.find((i) => i.id === icon)?.img) || missingIcon; + const variants = + ids.length > 1 + ? ids + .filter((id) => id !== actId) + .map((variantId) => { + const variant = acts.find((a) => a.id === variantId); + return variant + ? { + id: variantId, + icon: + (variant.icon === ICONS.OTHER + ? variant.url + : IconOpts.find((i) => i.id === variant.icon)?.img) || missingIcon, + title: variant.label, + } + : undefined; + }) + .filter(Boolean) + : undefined; + // const actId = selectedAct ? selectedAct.id : undefined; + return { + id, + title: label, + icon: imgSrc, + description: m(SlimdownView, { md: description, removeParagraphs: true }), + variants, + } as ProcessStep; + } else { + return undefined; + } + }) + .filter(Boolean) as ProcessStep[]; return m('.col.s12', [ - m( - 'h4', - `${label}${productIds.length > 0 ? ` (${toCommaSeparatedList(products, productIds).toLowerCase()})` : ''}` - ), + m('h4', `${label}${productIds.length > 0 ? ` (${toCommaSeparatedList(products, productIds)})` : ''}`), geoLocationIds.length > 0 && m( 'i.geo-location', @@ -188,7 +265,7 @@ ${selectedAct.measures ), description && m('p', description), m('.row', [ - m('.col.s4', [ + m('.col.s6.m4.l3', [ allCastIds.size > 0 && [ m('h5', t('CAST')), m( @@ -197,16 +274,28 @@ ${selectedAct.measures ), ], ]), - m('.col.s4', [ + m('.col.s6.m4.l3', [ + allSpIds.size > 0 && [ + m('h5', t('SERVICE_PROVIDERS')), + m( + 'ol', + Array.from(allSpIds).map((id) => m('li', serviceProviders.find((c) => c.id === id)?.label)) + ), + ], + ]), + m('.col.s6.m4.l3', [ allAttrIds.size > 0 && [ m('h5', t('ATTRIBUTES')), m( 'ol', - Array.from(allAttrIds).map((id) => m('li', attributes.find((c) => c.id === id)?.label)) + Array.from(allAttrIds) + .map((id) => attributes.find((c) => c.id === id)) + .filter((a) => a?.label) + .map((a) => m('li', a?.label)) ), ], ]), - m('.col.s4', [ + m('.col.s6.m4.l3', [ allLocIds.size > 0 && [ m('h5', t('LOCATIONS', allLocIds.size)), m( @@ -219,36 +308,23 @@ ${selectedAct.measures literature && literature.length > 0 && [m('h5', t('REFERENCES')), m(ReferenceListComponent, { references: literature })], m('h5', t('ACTS')), - m( - 'ul.collection', - allStages.map(({ stage, selectedVariant, title, act }) => { - const { id, label = '...', icon, url, description = '' } = act; - const imgSrc = (icon === ICONS.OTHER ? url : IconOpts.find((i) => i.id === icon)?.img) || missingIcon; - const actId = selectedAct ? selectedAct.id : undefined; - const onclick = () => { - stage.id = id; - update({ curActIdx: acts.findIndex((a) => a.id === id) }); - }; - return m( - 'li.collection-item.avatar.cursor-pointer', - { onclick, class: actId === id ? 'active' : undefined }, - [ - m('img.circle', { src: imgSrc, alt: label }), - m('span.title', title, selectedVariant ? m('sup', '*') : undefined), - m('p.markdown', m.trust(render(description, true))), - m( - 'a.secondary-content', - { href: window.location.href }, - m(Icon, { - iconName: 'more_horiz', - onclick, - }) - ), - ] - ); - }) - ), - selectedActContent && [m('h4', selectedActContent.title), selectedActContent.vnode], + m(ProcessVisualization, { + steps, + selectedStep: selectedAct?.id, + onStepSelect: (stepId) => update({ curActIdx: acts.findIndex((a) => a.id === stepId) }), + onVariantSelect: (stepId, variantId) => { + const stage = stages.find((s) => s.id === stepId); + if (stage) { + stage.id = variantId; + update({ curActIdx: acts.findIndex((a) => a.id === variantId) }); + } + }, + }), + selectedActContent && [ + m('h4', selectedActContent.title), + selectedAct?.activities && selectedAct?.activities?.length > 0 && m('h5', t('ACTIVITIES')), + selectedActContent.vnode, + ], measuresMd && m('div.markdown', m.trust(render(measuresMd))), ]); }, diff --git a/packages/gui/src/components/ui/process-visualisation.ts b/packages/gui/src/components/ui/process-visualisation.ts new file mode 100644 index 0000000..9ea573d --- /dev/null +++ b/packages/gui/src/components/ui/process-visualisation.ts @@ -0,0 +1,96 @@ +import m, { FactoryComponent, Vnode } from 'mithril'; + +export interface ProcessStep { + id: string; + icon: string; + title: string; + description?: string | Vnode; + variants?: ProcessVariant[]; +} + +export interface ProcessVariant { + id: string; + icon: string; + title: string; +} + +export interface ProcessVisualizationAttrs { + steps: ProcessStep[]; + onStepSelect?: (stepId: string) => void; + onVariantSelect?: (stepId: string, variantId: string) => void; + selectedStep?: string; + selectedVariant?: string; +} + +export const ProcessVisualization: FactoryComponent = () => { + let currentStep: string | null = null; + + const toggleStep = (stepId: string, attrs: ProcessVisualizationAttrs) => { + console.log('TOGGLE'); + currentStep = currentStep === stepId ? null : stepId; + if (attrs.onStepSelect) { + attrs.onStepSelect(stepId); + } + }; + + const selectVariant = (event: Event, stepId: string, variantId: string, attrs: ProcessVisualizationAttrs) => { + console.log('VARIANT'); + event.stopPropagation(); + if (attrs.onVariantSelect) { + attrs.onVariantSelect(stepId, variantId); + } + }; + + return { + view: ({ attrs }) => { + const { steps, selectedStep, selectedVariant } = attrs; + return m('.process-container', [ + steps.map((step, i) => + m( + '.process-step', + { + class: [ + selectedStep === step.id ? 'active' : '', + step.variants && step.variants.length ? 'has-variants' : '', + ] + .filter(Boolean) + .join(' ') + .trim(), + onclick: () => toggleStep(step.id, attrs), + }, + [ + m('.step-number', i + 1), + m('img.step-icon', { + src: step.icon, + alt: `${step.title} icon`, + }), + m('.step-content', [ + m('h4.step-title.truncate', step.title), + m('.step-description', step.description), + step.variants && + m('.variants-container', [ + step.variants.map((variant) => + m( + '.variant-option', + { + class: selectedVariant === variant.id ? 'active' : '', + onclick: (e: Event) => selectVariant(e, step.id, variant.id, attrs), + }, + [ + m('img.variant-icon', { + src: variant.icon, + alt: `${variant.title} icon`, + }), + variant.title, + ] + ) + ), + ]), + ]), + ] + ) + ), + ]); + }, + }; +}; diff --git a/packages/gui/src/css/process-visualisation.css b/packages/gui/src/css/process-visualisation.css new file mode 100644 index 0000000..c4eddd2 --- /dev/null +++ b/packages/gui/src/css/process-visualisation.css @@ -0,0 +1,126 @@ +.process-container { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0; + /* max-width: 1200px; */ + margin: 0 auto; + overflow-x: auto; +} + +.process-step { + flex: 1; + background: white; + border-radius: 8px; + padding: 1rem; + margin: 1rem; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + min-width: 200px; +} + +.process-step.has-variants { + border-left: 4px solid #3b82f6; +} + +.process-step:hover { + transform: translateY(-5px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +.process-step.active { + flex: 2; + max-width: fit-content; + color: #eafaf9; + background-color: #26a69a; +} + +.step-number { + position: absolute; + top: -10px; + left: -10px; + background: #3b82f6; + color: white; + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + font-weight: bold; +} + +.step-icon { + width: 64px; + height: 64px; + margin: 1rem auto; + display: block; +} + +.step-title { + font-weight: bold; + margin-bottom: 0.5rem; + /* color: #1f2937; */ + text-align: center; +} + +.step-description { + display: none; + /* color: #4b5563; */ + font-size: 0.875rem; + line-height: 1.4; + margin-top: 0.5rem; +} + +.process-step.active .step-description { + display: block; + max-width: auto; +} + +.variants-container { + display: none; + margin-top: 1rem; + border-top: 1px solid #e5e7eb; + padding-top: 1rem; +} + +.process-step.active .variants-container { + display: block; +} + +.variant-option { + padding: 0.5rem; + margin: 0.25rem 0; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.variant-option:hover { + color: black; + background: #f3f4f6; +} + +.variant-option.selected { + background: #dbeafe; +} + +.variant-icon { + width: 24px; + height: 24px; +} + +@media (max-width: 768px) { + .process-container { + flex-direction: column; + } + + .process-step { + width: 100%; + } +} diff --git a/packages/gui/src/css/style.css b/packages/gui/src/css/style.css index 8173766..3dd793f 100644 --- a/packages/gui/src/css/style.css +++ b/packages/gui/src/css/style.css @@ -1,4 +1,4 @@ -@import url('multi-select.css'); +@import url('process-visualisation.css'); @media only screen and (min-width: 601px) { .container { @@ -852,3 +852,12 @@ li input[type='text'] { border: 2px solid #2668a6; /* Teal border to indicate selection */ box-shadow: 0 4px 8px rgba(38, 115, 166, 0.5); } +.multi-select-dropdown { + margin-top: 3px; +} +.multi-select-dropdown > label { + transform: translateY(-12px) scale(0.8); +} +.input-field.select-space { + margin-bottom: 6px; +} diff --git a/packages/gui/src/models/data-model.ts b/packages/gui/src/models/data-model.ts index 74d3f50..e559ec7 100644 --- a/packages/gui/src/models/data-model.ts +++ b/packages/gui/src/models/data-model.ts @@ -12,12 +12,12 @@ export type DataModel = { transports: Transport[]; opportunities: Opportunity[]; indicators: Indicator[]; - barriers: Barrier[]; partners: Partner[]; + serviceProviders: ServiceProvider[]; acts: Act[]; }; -export const defaultModel = { +export const defaultModel: DataModel = { version: 1, lastUpdate: new Date().valueOf(), crimeScripts: [], @@ -29,10 +29,10 @@ export const defaultModel = { transports: [], opportunities: [], indicators: [], - barriers: [], partners: [], + serviceProviders: [], acts: [], -} as DataModel; +}; export enum STATUS { FIRST_DRAFT = 1, @@ -92,6 +92,8 @@ export type Labeled = { id: ID; label: string; description?: string; + /** Abbreviation */ + abbrev?: string; /** Data image, base64 encoded */ url?: string; /** Icon */ @@ -121,6 +123,7 @@ export type CrimeScript = Labeled & { export type Measure = Labeled & { /** Category the measure belongs to, e.g. situational crime prevention or other */ cat: string; + partners: ID[]; }; export enum ATTRIBUTE_TYPE { @@ -162,10 +165,10 @@ export type Opportunity = Labeled & Hierarchical; export type Indicator = Labeled & Hierarchical; -export type Barrier = Labeled & Hierarchical & { parters: ID[] }; - export type Partner = Labeled & Hierarchical; +export type ServiceProvider = Labeled & Hierarchical; + export type CrimeLocation = Labeled & Hierarchical; export type CrimeScriptAttributes = Labeled & Hierarchical; @@ -184,20 +187,14 @@ export type Stage = { }; export type Act = Labeled & { - // preparation: ActivityPhase; - // preactivity: ActivityPhase; - // activity: ActivityPhase; - // postactivity: ActivityPhase; - /** Measures to prevent or stop crime */ + /** Locations to perform the activity */ + locationIds?: ID[]; + /** Barriers or measures to prevent or stop crime */ measures: Measure[]; /** Opportunities related to the act */ - opportunities: ID[]; + opportunities: Opportunity[]; /** Indicator of the act */ - indicators: ID[]; - /** Barriers to the act */ - barriers: ID[]; - /** Locations to perform the activity */ - locationIds?: ID[]; + indicators: Indicator[]; /** A list of activities that takes place in this phase */ activities: Activity[]; // description: string[]; @@ -219,6 +216,7 @@ export enum ActivityType { HAS_CAST = 1, HAS_ATTRIBUTES = 2, HAS_TRANSPORT = 4, + HAS_SERVICE_PROVIDER = 8, // HAS_CAST_ATTRIBUTES = 4, } @@ -228,7 +226,8 @@ export type Activity = Labeled & { type?: ActivityType | ActivityType[]; cast?: ID[]; attributes?: ID[]; - // conditions: Condition[]; + /** Service providers */ + sp: ID[]; transports?: ID[]; }; diff --git a/packages/gui/src/models/forms.ts b/packages/gui/src/models/forms.ts index 5cf548c..01baa7a 100644 --- a/packages/gui/src/models/forms.ts +++ b/packages/gui/src/models/forms.ts @@ -10,9 +10,6 @@ export type AttributeType = | 'locations' | 'geoLocations' | 'products' - | 'opportunities' - | 'indicators' - | 'barriers' | 'partners'; export const attrForm = (id: AttributeType, label: string, attr: Labeled[] = [], attrType: AttributeType) => [ @@ -41,17 +38,6 @@ export const attrForm = (id: AttributeType, label: string, attr: Labeled[] = [], : ([ { id: 'id', type: 'autogenerate', autogenerate: 'id' }, { id: 'label', type: 'textarea', className: 'col s12', label: t('DESCRIPTION') }, - attrType === 'barriers' - ? { - id: 'partners', - type: 'search_select', - multiple: true, - className: 'col s12', - label: t('PARTNERS'), - options: attr.filter(({ label }) => label), - } - : undefined, - // { id: 'url', type: 'base64', className: 'col s6', label: t('IMAGE') }, ].filter(Boolean) as UIForm), }, ]; diff --git a/packages/gui/src/models/situational-crime-prevention.ts b/packages/gui/src/models/situational-crime-prevention.ts index 99e7a09..fb2c8ce 100644 --- a/packages/gui/src/models/situational-crime-prevention.ts +++ b/packages/gui/src/models/situational-crime-prevention.ts @@ -188,7 +188,6 @@ export const lookupCrimeMeasure = () => { acc.set(cur.id, cur); return acc; }, new Map()); - console.log(cmo); return (id: string) => cmo.get(id); }; diff --git a/packages/gui/src/services/flex-search.ts b/packages/gui/src/services/flex-search.ts index e7ff883..c151156 100644 --- a/packages/gui/src/services/flex-search.ts +++ b/packages/gui/src/services/flex-search.ts @@ -84,7 +84,7 @@ export const flexSearchLookupUpdater: Service = { const { label = '', description = '', stages: actVariants = [] } = crimeScript; const flexLoc: FlexSearchResult = [crimeScriptIdx, -1, -1, SearchScore.OTHER_MATCH]; tokenize(label + ' ' + description, i18n.stopwords).forEach((word) => updateLookup(word, flexLoc)); - actVariants.forEach(({ ids }) => { + actVariants.forEach(({ ids = [] }) => { ids.forEach((actId) => { const actIdx = acts.findIndex((a) => a.id === actId); if (actIdx < 0) return; diff --git a/packages/gui/src/services/lang/en.ts b/packages/gui/src/services/lang/en.ts index e44d941..6eaf1b2 100644 --- a/packages/gui/src/services/lang/en.ts +++ b/packages/gui/src/services/lang/en.ts @@ -50,17 +50,15 @@ export const messages = { ACTIVITIES: 'Activities', CAST: 'Cast', CONDITIONS: 'Conditions', + ATTRIBUTE: 'Attribute', ATTRIBUTES: 'Attributes', + MEASURE: 'Measure', MEASURES: 'Measures', NEW_ACT: 'New Act', - PREPARATION_PHASE: 'Preparation Phase', - PRE_ACTIVITY_PHASE: 'Pre-Activity Phase', - ACTIVITY_PHASE: 'Activity Phase', - POST_ACTIVITY_PHASE: 'Post-Activity Phase', SUMMARY: 'Summary', IMAGE: 'Image', ACTIVITY: 'Activity', - SPECIFY: 'Specify', + SPECIFY: 'Specify attributes', CONDITION: 'Condition', CATEGORY: 'Category', SELECT: 'Select', @@ -205,6 +203,7 @@ export const messages = { PRODUCTS: { 0: 'Products', 1: 'Product', n: 'Products' }, LOCATIONS: { 0: 'Locations', 1: 'Location', n: 'Locations' }, GEOLOCATIONS: { 0: 'Geographic Locations', 1: 'Geographic Location', n: 'Geographic Locations' }, + TRANSPORT: 'Transport', TRANSPORTS: 'Transports', I18n: { editRepeat: '', @@ -225,6 +224,8 @@ export const messages = { PARTNERS: 'Partners', INDICATOR: 'Indicator', INDICATORS: 'Indicators', - BARRIER: 'Barrier', - BARRIERS: 'Barriers', + SERVICE_PROVIDER: 'Service Provider', + SERVICE_PROVIDERS: 'Service Providers', + STEPS: 'Steps', + HEADER: 'Header', }; diff --git a/packages/gui/src/services/lang/nl.ts b/packages/gui/src/services/lang/nl.ts index ccceaf0..9f9572f 100644 --- a/packages/gui/src/services/lang/nl.ts +++ b/packages/gui/src/services/lang/nl.ts @@ -48,21 +48,19 @@ export const messagesNL: typeof messages = { DELETE_SCRIPT: 'Script verwijderen', NEW_SCRIPT: 'Nieuw script', DELETE_SCRIPT_CONFIRM: 'Weet je zeker dat je het script "{name}" wilt verwijderen?', - ACTIVITIES: 'Activiteiten', CAST: 'Rollen', CONDITION: 'Gelegenheid', CONDITIONS: 'Gelegenheden', + ATTRIBUTE: 'Attribuut', ATTRIBUTES: 'Attributen', - MEASURES: 'Maatregelen', + MEASURE: 'Barrière', + MEASURES: 'Barrières', NEW_ACT: 'Nieuwe handeling', - PREPARATION_PHASE: 'Voorbereidingsfase', - PRE_ACTIVITY_PHASE: 'Voor-activiteitsfase', - ACTIVITY_PHASE: 'Activiteitsfase', - POST_ACTIVITY_PHASE: 'Na-activiteitsfase', SUMMARY: 'Samenvatting', IMAGE: 'Afbeelding', - ACTIVITY: 'Activiteit', - SPECIFY: 'Specificeer', + ACTIVITY: 'Stap', + ACTIVITIES: 'Stappen', + SPECIFY: 'Specificeer eigenschappen', CATEGORY: 'Categorie', SELECT: 'Selecteren', CREATE: 'Maken', @@ -82,8 +80,8 @@ export const messagesNL: typeof messages = { DOWNLOAD: 'Sla model op als JSON', PERMALINK: 'Maak permanente link', ROLE: 'Rol', - SELECT_ACT: 'Selecteer act om te bewerken', - SELECT_ACT_N: 'Selecteer een of meerdere fases', + SELECT_ACT: 'Selecteer scene om te bewerken', + SELECT_ACT_N: 'Selecteer een of meerdere scenes', /** Crime Prevention Techniques */ INCREASE_EFFORT: 'Verhoog de inspanning', @@ -207,6 +205,7 @@ export const messagesNL: typeof messages = { LOCATIONS: { 0: 'Locaties', 1: 'Locatie', n: 'Locaties' }, PRODUCTS: { 0: 'Producten', 1: 'Product', n: 'Producten' }, GEOLOCATIONS: { 0: 'Kaartlocatie', 1: 'Kaartlocatie', n: 'Kaartlocaties' }, + TRANSPORT: 'Transportmiddel', TRANSPORTS: 'Transportmiddelen', I18n: { editRepeat: 'Bewerk item', @@ -226,7 +225,9 @@ export const messagesNL: typeof messages = { PARTNER: 'Partner', PARTNERS: 'Partners', INDICATOR: 'Indicator', - INDICATORS: 'Indicators', - BARRIER: 'Barrière', - BARRIERS: 'Barrières', + INDICATORS: 'Indicatoren', + SERVICE_PROVIDER: 'Dienstverlener', + SERVICE_PROVIDERS: 'Dienstverleners', + STEPS: 'Stappen', + HEADER: 'Titel', }; diff --git a/packages/gui/src/services/translations.ts b/packages/gui/src/services/translations.ts index 8dd7763..3cab3a3 100644 --- a/packages/gui/src/services/translations.ts +++ b/packages/gui/src/services/translations.ts @@ -4,6 +4,7 @@ import { messages, messagesNL } from './lang'; import { I18n } from 'mithril-ui-form'; import stopwordsNl from 'stopwords-nl'; import stopwordsEn from 'stopwords-en'; +import { LanguageStemmer } from 'wasm-stemmers'; export type Languages = 'nl' | 'en'; @@ -45,6 +46,7 @@ export const i18n = { init, addOnChangeListener, loadAndSetLocale, + stemmer: undefined as undefined | LanguageStemmer, stopwords: [] as string[], }; @@ -60,6 +62,7 @@ async function init(locales: Locales, selectedLocale: Languages) { const defaultLocale = (Object.keys(locales) as Languages[]).filter((l) => (locales[l] as Locale).default).shift(); if (defaultLocale) { i18n.defaultLocale = defaultLocale || selectedLocale; + i18n.stemmer = new LanguageStemmer(i18n.defaultLocale); } document.documentElement.setAttribute('lang', selectedLocale); await loadAndSetLocale(selectedLocale); diff --git a/packages/gui/src/utils/index.ts b/packages/gui/src/utils/index.ts index 9883daa..2572af3 100644 --- a/packages/gui/src/utils/index.ts +++ b/packages/gui/src/utils/index.ts @@ -1,6 +1,16 @@ import { padLeft } from 'mithril-materialized'; -import { CrimeScriptFilter, FlexSearchResult, Hierarchical, ID, Labeled, Page, Pages, SearchResult } from '../models'; -import { t } from '../services'; +import { + CrimeScriptFilter, + FlexSearchResult, + Hierarchical, + ID, + Labeled, + Measure, + Page, + Pages, + SearchResult, +} from '../models'; +import { i18n, t } from '../services'; export const LANGUAGE = 'CSS_LANGUAGE'; export const SAVED = 'CSS_MODEL_SAVED'; @@ -238,46 +248,36 @@ export const toCommaSeparatedList = (arr: Array = [], ids: ID | ID[] = .join(', '); export const generateLabeledItemsMarkup = (items: Array = []): string => { - // Check if there are any header items - const hasHeaders = items.some((item) => item.header); - - // Initialize result string - let result = ''; - - // Initialize ordered list items - let listItems: string[] = []; - - // Process each item - items.forEach((item) => { - if (item.header && hasHeaders) { - // If we have collected list items, render them before the header - if (listItems.length > 0) { - result += `
    \n${listItems.join('\n')}\n
\n`; - listItems = []; + const [_, nested] = items.reduce( + (acc, cur) => { + const [isNested] = acc; + if (cur.header) { + (acc[0] = true), acc[1].push({ ...cur, children: [] }); + } else { + if (isNested) { + const lastItem = acc[1][acc[1].length - 1]; + if (lastItem) { + lastItem.children.push({ ...cur }); + } + } else { + acc[1].push({ ...cur, children: [] }); + } } - - // Add header with optional description - result += `
${item.label}
\n`; - if (item.description) { - result += `

${item.description}

\n`; - } - } else { - // Create list item with optional description - let listItem = `
  • ${item.label}`; - if (item.description) { - listItem += `
    ${item.description}`; - } - listItem += '
  • '; - listItems.push(listItem); - } - }); - - // If we have remaining list items, render them - if (listItems.length > 0) { - result += `
      \n${listItems.join('\n')}\n
    \n`; - } - - return result.trim(); + return acc; + }, + [false, []] as [isNested: boolean, nested: Array] + ); + + return ( + '
      ' + + nested + .map((item) => { + const children = item.children.length > 0 ? item.children.map((c) => `
    1. ${c.label}
    2. `).join('') : ''; + return `
    3. ${item.label}${children ? `
        ${children}
      ` : ''}
    4. `; + }) + .join('\n') + + '
    ' + ); }; export const crimeScriptFilterToText = (arr: Array = [], filter = {} as CrimeScriptFilter) => { @@ -299,14 +299,9 @@ export const crimeScriptFilterToText = (arr: Array = [], filter = {} as ]); }; -/** Tokenize a text by removing punctuation, splitting the text into words, lowercasing and removing stopwords and (almost) empty strings */ +/** Tokenize a text by removing punctuation, splitting the text into stems, lowercasing and removing stopwords and (almost) empty strings */ export const tokenize = (text: string = '', stopwords: string[]): string[] => { - // return tokenizer.tokenize(text).map((word) => stemmer.stem(word)); - return text - .replace(/[^\w\s]/g, '') // Remove punctuation - .split(/\s+/) // Split into words - .map((word) => word.toLowerCase()) // Convert to lowercase - .filter((word) => word.length > 2 && !stopwords.includes(word)); // Exclude stopwords and empty strings + return i18n.stemmer!.stemText(text).filter((word) => word.length > 2 && !stopwords.includes(word)); }; /** Aggregate all search results to determine the most relevant crimescript (and act of that crimescript) */ @@ -364,3 +359,71 @@ export const isSmallPage = (): boolean => { return width < 601; // && width <= 992; }; + +/** Sort labels alphabetically */ +export const sortByLabel = ({ label: labelA = '' }: Labeled, { label: labelB = '' }: Labeled) => + labelA.localeCompare(labelB); + +/** + * Converts an array of measures into a markdown-formatted string grouped by partner label. + * @param measures - An array of Measure objects. + * @param lookupPartner - A Map object used for looking up partner labels. + * @param findCrimeMeasure - A function that takes a string (id) and returns an object with properties id, icon?, label, and group. + * @returns A string containing markdown-formatted measures grouped by partner label. + */ +export const measuresToMarkdown = ( + measures: Measure[], + lookupPartner: Map, + findCrimeMeasure: (id: string) => + | { + id: string; + icon?: string; + label: string; + group: string; + } + | undefined +): string => { + debugger; + type PartnerMeasure = { + label: string; + cat?: string; + }; + const addMeasure = (partnerLabel: string, measure: Measure) => { + if (!groupedMeasures.has(partnerLabel)) { + groupedMeasures.set(partnerLabel, []); + } + groupedMeasures.get(partnerLabel)?.push({ + label: measure.label, + cat: findCrimeMeasure(measure.cat)?.label, + }); + }; + + const groupedMeasures = new Map(); + + const othersLabel = t('OTHER'); + for (const measure of measures) { + if (measure.partners && measure.partners.length) { + for (const partner of measure.partners) { + const partnerLabel = lookupPartner.get(partner)?.label || othersLabel; + addMeasure(partnerLabel, measure); + } + } else { + addMeasure(othersLabel, measure); + } + } + + let markdown = ''; + + const sortedKeys = Array.from(groupedMeasures.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map((a) => a[0]); + for (const partnerLabel of sortedKeys) { + const measures = groupedMeasures.get(partnerLabel) || []; + markdown += `###### ${partnerLabel}\n\n${measures + .map((measure, i) => `${i + 1}. **${measure.cat}**: ${measure.label}`) + .join('\n')}\n\n`; + } + + console.log(markdown); + return markdown; +}; diff --git a/packages/gui/src/utils/word.ts b/packages/gui/src/utils/word.ts index 88063b9..215bfec 100644 --- a/packages/gui/src/utils/word.ts +++ b/packages/gui/src/utils/word.ts @@ -12,20 +12,36 @@ import { import { saveAs } from 'file-saver'; import { ActivityType, CrimeScript, DataModel, Hierarchical, ID, Labeled } from '../models'; import { t } from '../services'; -import { addLeadingSpaces } from '.'; +import { addLeadingSpaces, measuresToMarkdown } from '.'; +import { lookupCrimeMeasure } from '../models/situational-crime-prevention'; const blue = '2F5496'; /** Convert a crime script to a markdown string. */ export const crimeScriptToMarkdown = (crimeScript: Partial, model: DataModel) => { const { description, stages = [], literature, productIds, geoLocationIds } = crimeScript; - const { acts, cast = [], attributes = [], transports = [], products = [], geoLocations = [], locations = [] } = model; - const phaseNames = [t('PREPARATION_PHASE'), t('PRE_ACTIVITY_PHASE'), t('ACTIVITY_PHASE'), t('POST_ACTIVITY_PHASE')]; - - const itemLookup = [...cast, ...attributes, ...transports, ...locations, ...products, ...geoLocations].reduce( - (acc, cur) => acc.set(cur.id, cur), - new Map() - ); + const { + acts, + cast = [], + serviceProviders = [], + attributes = [], + transports = [], + products = [], + partners = [], + geoLocations = [], + locations = [], + } = model; + + const itemLookup = [ + ...cast, + ...serviceProviders, + ...attributes, + ...transports, + ...locations, + ...products, + ...partners, + ...geoLocations, + ].reduce((acc, cur) => acc.set(cur.id, cur), new Map()); const md: string[] = []; @@ -76,14 +92,13 @@ export const crimeScriptToMarkdown = (crimeScript: Partial, model: } stages.forEach((stage, i) => { const stageActs = stage.ids.map((id) => acts.find((a) => a.id === id)).filter((a) => typeof a !== 'undefined'); - newHeading(`${t('SCENE')} ${i + 1}: ${stageActs.map((a) => a.label).join(' | ')}`, 1); + newHeading(`${t('ACT')} ${i + 1}: ${stageActs.map((a) => a.label).join(' | ')}`, 1); stageActs.forEach((act) => { newHeading(act.label, 2); act.description && md.push(act.description); - [{ ...act }].forEach((a, i) => { + [{ ...act }].forEach((a) => { if (a && ((a.activities && a.activities.length > 0) || (a.conditions && a.conditions.length > 0))) { - newHeading(phaseNames[i], 3); if (a.locationIds && a.locationIds.length > 0) { newHeading(t('LOCATIONS', a.locationIds.length), 3); md.push( @@ -109,6 +124,15 @@ export const crimeScriptToMarkdown = (crimeScript: Partial, model: .filter((l) => typeof l !== undefined); list.push(addLeadingSpaces(`- ${t('CAST')}: ${castNames.join(', ')}`, spaces)); } + if (type && type.includes(ActivityType.HAS_SERVICE_PROVIDER) && activity.sp) { + const spNames = activity.sp + .map((c) => { + const found = itemLookup.get(c); + return found ? found.label : undefined; + }) + .filter((l) => typeof l !== undefined); + list.push(addLeadingSpaces(`- ${t('SERVICE_PROVIDERS')}: ${spNames.join(', ')}`, spaces)); + } if (type && type.includes(ActivityType.HAS_ATTRIBUTES) && activity.attributes) { const attrNames = activity.attributes .map((c) => { @@ -139,6 +163,22 @@ export const crimeScriptToMarkdown = (crimeScript: Partial, model: conditionsTxt.push(''); md.push(...conditionsTxt); } + + if (a.indicators && a.indicators.length > 0) { + newHeading(t('INDICATORS'), 4); + const indicatorsTxt = a.indicators.reduce(createListItem, [] as string[]); + indicatorsTxt.push(''); + md.push(...indicatorsTxt); + } + + if (a.measures && a.measures.length > 0) { + newHeading(t('MEASURES'), 4); + const measureMd = measuresToMarkdown(a.measures, itemLookup, lookupCrimeMeasure()); + md.push(measureMd); + // const measuresTxt = a.measures.reduce(createListItem, [] as string[]); + // measuresTxt.push(''); + // md.push(...measuresTxt); + } } }); }); @@ -508,6 +548,18 @@ const convertMarkdownToDocxParagraphs = (markdown?: string): Paragraph[] => { children: parseFormattedText(trimmedLine.substring(5)), heading: HeadingLevel.HEADING_4, }); + } else if (trimmedLine.startsWith('##### ')) { + currentListInstance = null; + return new Paragraph({ + children: parseFormattedText(trimmedLine.substring(6)), + heading: HeadingLevel.HEADING_5, + }); + } else if (trimmedLine.startsWith('###### ')) { + currentListInstance = null; + return new Paragraph({ + children: parseFormattedText(trimmedLine.substring(7)), + heading: HeadingLevel.HEADING_6, + }); } else if (listItem) { if (listItem.ordered) { if (currentListInstance === null) {