doxa-maps/plugin-designs/pplr-data-maps/src/components/FlatLegend.vue
doxa-maps-88655c70 e1d847028d PPLR data maps (WIP): move the ten PPLR research views into plugin-designs
Not for main yet. All ten views mount on the dev server with real data and no
page errors. Left: the coverage view's status still says 'sample of 6,000',
its choropleth needs checking, and the bundle is not built or tree-verified.
2026-09-25 06:33:49 +00:00

215 lines
7.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
/**
* FlatLegend — non-tree, single-tab companion to SemanticTreeLegend.
*
* For datasets with a flat color taxonomy (e.g. religion groups, country
* clusters, simple binary states) — anything that doesn't need the tabbed
* generation drill-down. Same chrome (rounded corners, dark/light theme,
* collapse-to-chip under the search bar) as SemanticTreeLegend so the two
* read as siblings, not separate widgets.
*
* Item shape:
* { key: string, label: string, color: string, count?: number, filter?: any }
*
* Selection emits `@select` with the item or null. The parent map is
* responsible for `setFilter` — this component does not touch the map.
*
* Mediator store hookup: optional. If `usePplrInstance.useInstance()` returns
* a store, FlatLegend reads `instance.theme.value` for [data-theme] styling
* and writes selection back to `instance.selection.value`.
*/
import { ref, computed } from 'vue'
import { useInstance } from '../composables/usePplrInstance.js'
const props = defineProps({
items: { type: Array, default: () => [] },
title: { type: String, default: 'Legend' },
showCount:{ type: Boolean, default: true },
})
const emit = defineEmits(['select'])
const instance = useInstance()
const collapsed = ref(false)
const theme = computed(() => instance?.theme.value || 'dark')
const selectedKey = computed(() => {
const sel = instance?.selection.value
if (!sel) return null
// Selection from this legend uses the item.key as its id; from the geocoder it
// may use a 'rel:<key>' prefix. Tolerate both.
if (typeof sel.id === 'string' && sel.id.startsWith('rel:')) return sel.id.slice(4)
return sel.id || sel.key || null
})
function pick(item) {
const isSame = selectedKey.value === item.key
if (isSame) {
if (instance) instance.selection.value = null
emit('select', null)
return
}
const node = {
id: `rel:${item.key}`,
label: item.label,
color: item.color,
filter: item.filter,
kind: 'religion',
}
if (instance) instance.selection.value = node
emit('select', node)
}
function clearSel() {
if (instance) instance.selection.value = null
emit('select', null)
}
</script>
<template>
<div class="fl-panel" :class="{ collapsed }" :data-theme="theme" v-show="!collapsed">
<div class="fl-titlebar">
<button class="fl-collapse-btn" :title="collapsed ? 'Expand legend' : 'Collapse legend'"
@click="collapsed = true">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
<polyline points="14,18 8,12 14,6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="fl-title">{{ title }}</div>
<button v-if="selectedKey" class="fl-clear" title="Clear selection (Esc)" @click="clearSel">×</button>
</div>
<div class="fl-rows" :class="{ 'has-sel': !!selectedKey }">
<button
v-for="item in items"
:key="item.key"
class="fl-row"
:class="{ selected: selectedKey === item.key }"
:data-row-id="item.key"
:style="{ '--row-color': item.color }"
@click="pick(item)"
>
<span class="fl-dot" :style="{ background: item.color }" />
<span class="fl-label">{{ item.label }}</span>
<span v-if="showCount && item.count != null" class="fl-count">{{ item.count.toLocaleString() }}</span>
</button>
</div>
</div>
<button v-if="collapsed"
class="fl-reopen"
:data-theme="theme"
title="Open legend"
@click="collapsed = false">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
<polyline points="9,18 15,12 9,6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>Legend</span>
</button>
</template>
<style scoped>
.fl-panel {
position: absolute; top: 60px; left: 8px; width: 320px;
background: rgba(13,17,23,0.96); color: #c9d1d9;
border: 1px solid #30363d; border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
z-index: 4; font: 12px system-ui, sans-serif;
max-height: calc(100% - 80px);
display: flex; flex-direction: column;
overflow: hidden;
}
.fl-panel[data-theme="light"] {
background: rgba(255,255,255,0.97); color: #1f2328;
border-color: #d0d7de;
box-shadow: 0 8px 24px rgba(31,35,40,0.18);
}
/* Title bar */
.fl-titlebar {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid #21262d;
}
.fl-panel[data-theme="light"] .fl-titlebar { border-bottom-color: #d8dee4; }
.fl-collapse-btn {
background: transparent; border: none; cursor: pointer;
color: #8b949e; padding: 2px 4px; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
}
.fl-collapse-btn:hover { color: #c9d1d9; background: rgba(115,161,127,0.12); }
.fl-panel[data-theme="light"] .fl-collapse-btn { color: #57606a; }
.fl-panel[data-theme="light"] .fl-collapse-btn:hover { color: #1f2328; background: #f6f8fa; }
.fl-title {
flex: 1;
font: 600 11px ui-monospace, monospace;
text-transform: uppercase; letter-spacing: 0.06em;
color: #73A17F;
}
.fl-panel[data-theme="light"] .fl-title { color: #3b463d; }
.fl-clear {
background: transparent; border: none; cursor: pointer;
color: #8b949e; font-size: 16px; line-height: 1; padding: 2px 6px;
border-radius: 4px;
}
.fl-clear:hover { background: #ef4444; color: #fff; }
.fl-panel[data-theme="light"] .fl-clear { color: #57606a; }
/* Rows */
.fl-rows { overflow-y: auto; padding: 4px; }
.fl-row {
width: 100%;
display: grid;
grid-template-columns: 14px 1fr auto;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: transparent; border: none; cursor: pointer;
color: inherit; text-align: left;
font: 12px system-ui, sans-serif;
border-radius: 6px;
transition: opacity 0.12s, background 0.12s;
}
.fl-row:hover { background: rgba(115,161,127,0.08); }
.fl-panel[data-theme="light"] .fl-row:hover { background: #f6f8fa; }
.fl-rows.has-sel .fl-row { opacity: 0.3; }
.fl-rows.has-sel .fl-row.selected {
opacity: 1;
background: rgba(255,255,255,0.05);
box-shadow:
inset 3px 0 0 0 var(--row-color, #73A17F),
inset 0 0 0 1px rgba(255,255,255,0.05);
}
.fl-panel[data-theme="light"] .fl-rows.has-sel .fl-row.selected {
background: rgba(0,0,0,0.04);
box-shadow:
inset 3px 0 0 0 var(--row-color, #3b463d),
inset 0 0 0 1px rgba(0,0,0,0.05);
}
.fl-dot { width: 11px; height: 11px; border-radius: 50%; }
.fl-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fl-count {
font: 11px ui-monospace, monospace;
color: #6e7681;
}
.fl-panel[data-theme="light"] .fl-count { color: #57606a; }
/* Collapsed chip — sibling of the panel, sits where the search-bar's bottom edge is */
.fl-reopen {
position: absolute; top: 60px; left: 8px; z-index: 3;
height: 36px; border-radius: 999px;
background: rgba(13,17,23,0.96); color: #c9d1d9;
border: 1px solid #30363d;
padding: 0 14px 0 10px;
display: inline-flex; align-items: center; gap: 6px;
cursor: pointer;
font: 600 11px ui-monospace, monospace;
text-transform: uppercase; letter-spacing: 0.05em;
box-shadow: 0 4px 14px rgba(0,0,0,0.35);
}
.fl-reopen[data-theme="light"] {
background: rgba(255,255,255,0.97); color: #1f2328; border-color: #d0d7de;
box-shadow: 0 4px 14px rgba(31,35,40,0.15);
}
.fl-reopen:hover { border-color: #73A17F; }
</style>