From dabafbe9478aefc8465f63a46c1aa60d56de1228 Mon Sep 17 00:00:00 2001
From: Xiao Gui <xgui3783@gmail.com>
Date: Tue, 29 Nov 2022 18:02:29 +0100
Subject: [PATCH] feat: alternative ATP selector

---
 .storybook/preview-head.html                  |   5 +
 src/atlasComponents/sapi/stories.base.ts      |  10 ++
 .../sapiViews/core/parcellation/index.ts      |  17 +-
 .../parcellationGroupSelected.pipe.ts         |  20 +++
 .../sapiViews/core/rich/ATPSelector/index.ts  |   3 +
 .../sapiViews/core/rich/ATPSelector/module.ts |  41 +++++
 .../pureDumb/pureATPSelector.components.ts    |  85 ++++++++++
 .../pureDumb/pureATPSelector.stories.ts       | 139 +++++++++++++++
 .../pureDumb/pureATPSelector.style.scss       |  30 ++++
 .../pureDumb/pureATPSelector.template.html    | 122 +++++++++++++
 .../core/rich/ATPSelector/story.base.ts       |  42 +++++
 .../ATPSelector/wrapper/wrapper.component.ts  | 137 +++++++++++++++
 .../ATPSelector/wrapper/wrapper.stories.ts    |  81 +++++++++
 .../ATPSelector/wrapper/wrapper.style.css     |   5 +
 .../ATPSelector/wrapper/wrapper.template.html |  18 ++
 .../component/smartChip.component.ts          |  88 ++++++++++
 .../smartChip/component/smartChip.style.css   |  34 ++++
 .../component/smartChip.template.html         |  61 +++++++
 src/components/smartChip/hasSubmenu.pipe.ts   |  12 ++
 src/components/smartChip/index.ts             |   3 +
 src/components/smartChip/module.ts            |  31 ++++
 .../smartChip/smartChip.content.directive.ts  |   9 +
 .../smartChip/smartChip.menu.directive.ts     |   9 +
 src/components/smartChip/smartChip.stories.ts | 160 ++++++++++++++++++
 src/state/atlasSelection/util.ts              |  30 ++--
 src/ui/dialogInfo/dialog.directive.ts         |  15 +-
 src/ui/dialogInfo/index.ts                    |   4 +-
 src/ui/dialogInfo/module.ts                   |  12 +-
 src/ui/dialogInfo/tmpl/tmpl.component.ts      |  25 +++
 src/ui/dialogInfo/tmpl/tmpl.stories.ts        |   0
 src/ui/dialogInfo/tmpl/tmpl.style.css         |   4 +
 src/ui/dialogInfo/tmpl/tmpl.template.html     |  40 +++++
 src/util/recursivePartial.ts                  |   3 +
 33 files changed, 1260 insertions(+), 35 deletions(-)
 create mode 100644 src/atlasComponents/sapiViews/core/parcellation/parcellationGroupSelected.pipe.ts
 create mode 100644 src/atlasComponents/sapiViews/core/rich/ATPSelector/index.ts
 create mode 100644 src/atlasComponents/sapiViews/core/rich/ATPSelector/module.ts
 create mode 100644 src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.components.ts
 create mode 100644 src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.stories.ts
 create mode 100644 src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.style.scss
 create mode 100644 src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.template.html
 create mode 100644 src/atlasComponents/sapiViews/core/rich/ATPSelector/story.base.ts
 create mode 100644 src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.component.ts
 create mode 100644 src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.stories.ts
 create mode 100644 src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.style.css
 create mode 100644 src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.template.html
 create mode 100644 src/components/smartChip/component/smartChip.component.ts
 create mode 100644 src/components/smartChip/component/smartChip.style.css
 create mode 100644 src/components/smartChip/component/smartChip.template.html
 create mode 100644 src/components/smartChip/hasSubmenu.pipe.ts
 create mode 100644 src/components/smartChip/index.ts
 create mode 100644 src/components/smartChip/module.ts
 create mode 100644 src/components/smartChip/smartChip.content.directive.ts
 create mode 100644 src/components/smartChip/smartChip.menu.directive.ts
 create mode 100644 src/components/smartChip/smartChip.stories.ts
 create mode 100644 src/ui/dialogInfo/tmpl/tmpl.component.ts
 create mode 100644 src/ui/dialogInfo/tmpl/tmpl.stories.ts
 create mode 100644 src/ui/dialogInfo/tmpl/tmpl.style.css
 create mode 100644 src/ui/dialogInfo/tmpl/tmpl.template.html
 create mode 100644 src/util/recursivePartial.ts

diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html
index f63b14cd9..1095b2dd6 100644
--- a/.storybook/preview-head.html
+++ b/.storybook/preview-head.html
@@ -8,6 +8,11 @@
   {
     overflow: scroll!important;
   }
+  #root
+  {
+    width: 100%;
+    height: 100%;
+  }
 </style>
 <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/all.css" integrity="sha384-50oBUHEmvpQ+1lW4y57PTFmhCaXp0ML5d60M1M7uH2+nqUivzIebhndOJK28anvf" crossorigin="anonymous">
 <script type="module" src="https://unpkg.com/hbp-connectivity-component@0.6.5/dist/connectivity-component/connectivity-component.js" defer></script>
diff --git a/src/atlasComponents/sapi/stories.base.ts b/src/atlasComponents/sapi/stories.base.ts
index 57e28f20d..9b9037ba6 100644
--- a/src/atlasComponents/sapi/stories.base.ts
+++ b/src/atlasComponents/sapi/stories.base.ts
@@ -76,6 +76,11 @@ export async function getAtlas(id: string): Promise<SapiAtlasModel>{
   return await (await fetch(`${endPt}/atlases/${id}`)).json()
 }
 
+export async function getParcellations(atlasId: string): Promise<SapiParcellationModel[]> {
+  const endPt = await SAPI.BsEndpoint$.toPromise()
+  return await (await fetch(`${endPt}/atlases/${atlasId}/parcellations`)).json()
+}
+
 export async function getParc(atlasId: string, id: string): Promise<SapiParcellationModel>{
   const endPt = await SAPI.BsEndpoint$.toPromise()
   return await (await fetch(`${endPt}/atlases/${atlasId}/parcellations/${id}`)).json()
@@ -85,6 +90,11 @@ export async function getParcRegions(atlasId: string, id: string, spaceId: strin
   return await (await fetch(`${endPt}/atlases/${atlasId}/parcellations/${id}/regions?space_id=${encodeURIComponent(spaceId)}`)).json()
 }
 
+export async function getSpaces(atlasId: string): Promise<SapiSpaceModel[]> {
+  const endPt = await SAPI.BsEndpoint$.toPromise()
+  return await (await fetch(`${endPt}/atlases/${atlasId}/spaces`)).json()
+}
+
 export async function getSpace(atlasId: string, id: string): Promise<SapiSpaceModel> {
   const endPt = await SAPI.BsEndpoint$.toPromise()
   return await (await fetch(`${endPt}/atlases/${atlasId}/spaces/${id}`)).json()
diff --git a/src/atlasComponents/sapiViews/core/parcellation/index.ts b/src/atlasComponents/sapiViews/core/parcellation/index.ts
index 801b1f3e3..78616fb9b 100644
--- a/src/atlasComponents/sapiViews/core/parcellation/index.ts
+++ b/src/atlasComponents/sapiViews/core/parcellation/index.ts
@@ -1,11 +1,6 @@
-export {
-  SapiViewsCoreParcellationModule
-} from "./module"
-
-export {
-  FilterGroupedParcellationPipe
-} from "./filterGroupedParcellations.pipe"
-
-export {
-  FilterUnsupportedParcPipe
-} from "./filterUnsupportedParc.pipe"
\ No newline at end of file
+export { SapiViewsCoreParcellationModule } from "./module"
+export { FilterGroupedParcellationPipe } from "./filterGroupedParcellations.pipe"
+export { FilterUnsupportedParcPipe } from "./filterUnsupportedParc.pipe"
+export { GroupedParcellation } from "./groupedParcellation"
+export { ParcellationDoiPipe } from "./parcellationDoi.pipe"
+export { ParcellationGroupSelectedPipe } from "./parcellationGroupSelected.pipe"
diff --git a/src/atlasComponents/sapiViews/core/parcellation/parcellationGroupSelected.pipe.ts b/src/atlasComponents/sapiViews/core/parcellation/parcellationGroupSelected.pipe.ts
new file mode 100644
index 000000000..cd31e4391
--- /dev/null
+++ b/src/atlasComponents/sapiViews/core/parcellation/parcellationGroupSelected.pipe.ts
@@ -0,0 +1,20 @@
+import { Pipe, PipeTransform } from "@angular/core";
+import { SapiParcellationModel } from "src/atlasComponents/sapi/type";
+import { GroupedParcellation } from "./groupedParcellation";
+
+function isGroupedParc(parc: GroupedParcellation|unknown): parc is GroupedParcellation {
+  if (!parc['parcellations']) return false
+  return (parc['parcellations'] as SapiParcellationModel[]).every(p => p["@type"] === "minds/core/parcellationatlas/v1.0.0")
+}
+
+@Pipe({
+  name: 'parcellationGroupSelected',
+  pure: true
+})
+
+export class ParcellationGroupSelectedPipe implements PipeTransform {
+  public transform(parc: GroupedParcellation|unknown, selectedParcellation: SapiParcellationModel): boolean {
+    if (!isGroupedParc(parc)) return false
+    return parc.parcellations.some(p => p["@id"] === selectedParcellation["@id"])
+  }
+}
diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/index.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/index.ts
new file mode 100644
index 000000000..6a8a2025a
--- /dev/null
+++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/index.ts
@@ -0,0 +1,3 @@
+export {
+  ATPSelectorModule
+} from "./module"
\ No newline at end of file
diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/module.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/module.ts
new file mode 100644
index 000000000..59d64b311
--- /dev/null
+++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/module.ts
@@ -0,0 +1,41 @@
+import { CommonModule } from "@angular/common";
+import { NgModule } from "@angular/core";
+import { MatButtonModule } from "@angular/material/button";
+import { MatRippleModule } from "@angular/material/core";
+import { MatIconModule } from "@angular/material/icon";
+import { MarkdownModule } from "src/components/markdown";
+import { SmartChipModule } from "src/components/smartChip";
+import { DialogModule } from "src/ui/dialogInfo";
+import { UtilModule } from "src/util";
+import { FilterGroupedParcellationPipe, ParcellationDoiPipe, ParcellationGroupSelectedPipe } from "src/atlasComponents/sapiViews/core/parcellation";
+import { PureATPSelector } from "./pureDumb/pureATPSelector.components";
+import { WrapperATPSelector } from "./wrapper/wrapper.component";
+import { SAPIModule } from "src/atlasComponents/sapi/module";
+
+@NgModule({
+  imports: [
+    CommonModule,
+    SmartChipModule,
+    UtilModule,
+    MarkdownModule,
+    MatRippleModule,
+    MatIconModule,
+    MatButtonModule,
+    DialogModule,
+    SAPIModule,
+  ],
+  declarations: [
+    PureATPSelector,
+    WrapperATPSelector,
+
+    FilterGroupedParcellationPipe,
+    ParcellationDoiPipe,
+    ParcellationGroupSelectedPipe,
+  ],
+  exports: [
+    PureATPSelector,
+    WrapperATPSelector,
+  ]
+})
+
+export class ATPSelectorModule{}
diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.components.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.components.ts
new file mode 100644
index 000000000..99dbb387b
--- /dev/null
+++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.components.ts
@@ -0,0 +1,85 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core";
+import { SapiAtlasModel, SapiParcellationModel, SapiSpaceModel } from "src/atlasComponents/sapi/type";
+import { FilterGroupedParcellationPipe, GroupedParcellation } from "src/atlasComponents/sapiViews/core/parcellation";
+
+export const defaultColorPalette = [
+  "#480202",
+  "#6b1205",
+  "#921d1d",
+]
+
+export type ATP = {
+  atlas: SapiAtlasModel
+  template: SapiSpaceModel
+  parcellation: SapiParcellationModel
+}
+const pipe = new FilterGroupedParcellationPipe()
+
+
+@Component({
+  selector: 'sxplr-pure-atp-selector',
+  templateUrl: `./pureATPSelector.template.html`,
+  styleUrls: [
+    `./pureATPSelector.style.scss`
+  ],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+
+export class PureATPSelector implements OnChanges{
+
+  @Input('sxplr-pure-atp-selector-color-palette')
+  colorPalette: string[] = defaultColorPalette
+
+  @Input(`sxplr-pure-atp-selector-selected-atp`)
+  public selectedATP: ATP
+
+  public selectedIds: string[] = []
+
+  @Input(`sxplr-pure-atp-selector-atlases`)
+  public allAtlases: SapiAtlasModel[] = []
+
+  @Input(`sxplr-pure-atp-selector-templates`)
+  public availableTemplates: SapiSpaceModel[] = []
+
+  @Input(`sxplr-pure-atp-selector-parcellations`)
+  public parcellations: SapiParcellationModel[] = []
+
+  public parcAndGroup: (GroupedParcellation|SapiParcellationModel)[] = []
+
+  @Input('sxplr-pure-atp-selector-is-busy')
+  public isBusy: boolean = false
+
+  @Output('sxplr-pure-atp-selector-on-select')
+  selectLeafEmitter = new EventEmitter<ATP>()
+
+  getChildren(parc: GroupedParcellation|SapiParcellationModel){
+    return (parc as GroupedParcellation).parcellations || []
+  }
+
+  selectLeaf(atp: ATP) {
+    if (this.isBusy) return
+    this.selectLeafEmitter.emit(atp)
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes.selectedATP) {
+      if (!changes.selectedATP.currentValue) {
+        this.selectedIds = []
+      } else {
+        const { atlas, parcellation, template } = changes.selectedATP.currentValue as ATP
+        this.selectedIds = [atlas?.["@id"], parcellation?.["@id"], template?.["@id"]].filter(v => !!v)
+      }
+    }
+
+    if (changes.parcellations) {
+      if (!changes.parcellations.currentValue) {
+        this.parcAndGroup = []
+      } else {
+        this.parcAndGroup = [
+          ...pipe.transform(changes.parcellations.currentValue, true),
+          ...pipe.transform(changes.parcellations.currentValue, false),
+        ]
+      }
+    }
+  }
+}
diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.stories.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.stories.ts
new file mode 100644
index 000000000..cb0eeafc5
--- /dev/null
+++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.stories.ts
@@ -0,0 +1,139 @@
+import { CommonModule } from "@angular/common";
+import { Component } from "@angular/core";
+import { MatButtonModule } from "@angular/material/button";
+import { action } from "@storybook/addon-actions";
+import { componentWrapperDecorator, Meta, moduleMetadata, Story } from "@storybook/angular";
+import { atlasId, provideDarkTheme } from "src/atlasComponents/sapi/stories.base";
+import { SapiAtlasModel, SapiParcellationModel, SapiSpaceModel } from "src/atlasComponents/sapi/type";
+import { UtilModule } from "src/util";
+import { ATPSelectorModule } from "../module";
+import { defaultColorPalette } from "./pureATPSelector.components"
+import { loadAtlasEtcData, wrapperDecoratorFn } from "../story.base"
+
+@Component({
+  selector: 'atlas-selector-wrapper-story',
+  template: 
+  `<sxplr-pure-atp-selector
+    [sxplr-pure-atp-selector-color-palette]="[atlasColor, spaceColor, parcellationColor]"
+    [sxplr-pure-atp-selector-selected-atp]="selectedATP"
+    [sxplr-pure-atp-selector-atlases]="allAtlases"
+    [sxplr-pure-atp-selector-templates]="availableTemplates"
+    [sxplr-pure-atp-selector-parcellations]="parcs"
+    [sxplr-pure-atp-selector-is-busy]="isBusy"
+    (sxplr-pure-atp-selector-on-select)="selectLeaf($event)"
+    >
+    <button mat-icon-button
+      (click)="toggleParcellationVisibility()"
+      parcellation-chip-suffix
+      iav-stop="mousedown click">
+      <i class="fas fa-eye"></i>
+    </button>
+  </sxplr-pure-atp-selector>`,
+  styles: [
+    `[parcellation-chip-suffix]
+    {
+      margin-right: -1rem;
+      margin-left: 0.2rem;
+    }`
+  ]
+})
+class AtlasLayerSelectorWrapper {
+  atlasColor: string = defaultColorPalette[0]
+  spaceColor: string = defaultColorPalette[1]
+  parcellationColor: string = defaultColorPalette[2]
+  allAtlases: SapiAtlasModel[]
+  availableTemplates: SapiSpaceModel[]
+  selectedATP: {
+    atlas: SapiAtlasModel
+    parcellation: SapiParcellationModel
+    template: SapiSpaceModel
+  }
+  parcs: SapiParcellationModel[]
+  isBusy: boolean = false
+
+  selectLeaf(arg: {
+    atlas: SapiAtlasModel
+    parcellation: SapiParcellationModel
+    template: SapiSpaceModel
+  }) {}
+
+  toggleParcellationVisibility() {
+
+  }
+}
+
+export default {
+  component: AtlasLayerSelectorWrapper,
+  decorators: [
+    moduleMetadata({
+      imports: [
+        CommonModule,
+        ATPSelectorModule,
+        MatButtonModule,
+        UtilModule,
+      ],
+      declarations: [
+        AtlasLayerSelectorWrapper
+      ],
+      providers: [
+        ...provideDarkTheme,
+      ]
+    }),
+    componentWrapperDecorator(wrapperDecoratorFn)
+  ]
+} as Meta
+
+const Template: Story<AtlasLayerSelectorWrapper> = (args: AtlasLayerSelectorWrapper, { loaded }) => {
+  
+  const {
+    combinedAtlas,
+    atlases
+  } = loaded
+  const { atlas, parcs, spaces } = combinedAtlas
+  const selectedATP = {
+    atlas, parcellation: parcs[0], template: spaces[0]
+  }
+  const {
+    atlasColor,
+    spaceColor,
+    parcellationColor,
+  } = args
+  return ({
+    props: {
+      ...args,
+      allAtlases: atlases,
+      parcs,
+      availableTemplates: spaces,
+      selectedATP: {
+        atlas,
+        parcellation: parcs[0],
+        template: spaces[0]
+      },
+      selectLeaf: action(`selectLeaf`),
+      toggleParcellationVisibility: action('toggleParcellationVisibility'),
+      atlasColor,
+      spaceColor,
+      parcellationColor,
+    }
+  })
+}
+
+export const Default = Template.bind({})
+Default.args = {
+  atlasColor: defaultColorPalette[0],
+  spaceColor: defaultColorPalette[1],
+  parcellationColor: defaultColorPalette[2],
+}
+Default.loaders = [
+  async () => {
+    const {
+      atlases,
+      combinedAtlases
+    } = await loadAtlasEtcData()
+    
+    return {
+      atlases,
+      combinedAtlas: combinedAtlases.filter(v => v.atlas["@id"] === atlasId.human)[0]
+    }
+  }
+]
diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.style.scss b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.style.scss
new file mode 100644
index 000000000..04f664219
--- /dev/null
+++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.style.scss
@@ -0,0 +1,30 @@
+:host
+{
+  display: inline-flex;
+  flex-direction: row-reverse;
+}
+
+.full-sized-button
+{
+  width: 100%;
+  text-align: left;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  overflow: hidden;
+}
+
+.icons
+{
+  margin-right: -1rem;
+  margin-left: 0.2rem;
+}
+
+sxplr-smart-chip:not(:last-child)
+{
+  margin-left: -3.5rem;
+
+  .chip-text 
+  {
+    padding-left: 1rem;
+  }
+}
diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.template.html b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.template.html
new file mode 100644
index 000000000..cd071fbba
--- /dev/null
+++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/pureDumb/pureATPSelector.template.html
@@ -0,0 +1,122 @@
+<ng-template [ngIf]="selectedATP" let-ATP>
+
+  <!-- parcellation smart chip -->
+  <sxplr-smart-chip *ngIf="ATP.parcellation"
+    [items]="parcAndGroup"
+    [color]="colorPalette[2]"
+    [getChildren]="getChildren"
+    (itemClicked)="selectLeaf({ parcellation: $event })"
+    [elevation]="2"
+    [disabled]="isBusy">
+    <ng-template sxplrSmartChipContent>
+      <span class="chip-text">
+        <span>
+      </span>
+        {{ ATP.parcellation.name }}
+      </span>
+
+      <ng-content select="[parcellation-chip-suffix]">
+      </ng-content>
+
+      <button iav-stop="mousedown click"
+        class="icons"
+        mat-icon-button
+        sxplr-dialog
+        [sxplr-dialog-size]="null"
+        [sxplr-dialog-data]="{
+          title: ATP.parcellation.name || ATP.parcellation.fullName,
+          descMd: (ATP.parcellation.brainAtlasVersions || [])[0]?.versionInnovation,
+          actions: ATP.parcellation | parcellationDoiPipe
+        }">
+        <i class="fas fa-info"></i>
+      </button>
+    </ng-template>
+    <ng-template sxplrSmartChipMenu let-parc>
+
+      <ng-container *ngTemplateOutlet="optionTmpl; context: {
+        $implicit: parc,
+        overridePrefixIconTmpl: (parc | parcellationGroupSelected : ATP.parcellation)
+          ? halfSelectedTmpl
+          : null
+      }">
+      </ng-container>
+    </ng-template>
+  </sxplr-smart-chip>
+
+  <!-- space smart chip -->
+  <sxplr-smart-chip *ngIf="ATP.template"
+    [items]="availableTemplates"
+    [color]="colorPalette[1]"
+    (itemClicked)="selectLeaf({ template: $event })"
+    [elevation]="4"
+    [disabled]="isBusy">
+    <ng-template sxplrSmartChipContent>
+      <span class="chip-text">
+        {{ ATP.template.fullName }}
+      </span>
+    </ng-template>
+    <ng-template sxplrSmartChipMenu let-space>
+      <ng-container *ngTemplateOutlet="optionTmpl; context: { $implicit: space }"></ng-container>
+    </ng-template>
+  </sxplr-smart-chip>
+
+  <!-- atlas smart chip -->
+  <sxplr-smart-chip *ngIf="ATP.atlas"
+    [items]="allAtlases"
+    [color]="colorPalette[0]"
+    (itemClicked)="selectLeaf({ atlas: $event})"
+    [elevation]="6"
+    [disabled]="isBusy">
+    <ng-template sxplrSmartChipContent>
+      <span class="chip-text">
+        {{ ATP.atlas.name }}
+      </span>
+    </ng-template>
+    <ng-template sxplrSmartChipMenu let-atlas>
+      <ng-container *ngTemplateOutlet="optionTmpl; context: { $implicit: atlas }"></ng-container>
+    </ng-template>
+  </sxplr-smart-chip>
+</ng-template>
+
+<!-- half selected -->
+<!-- only active in nested menus (e.g. parcellation groups) -->
+<ng-template #halfSelectedTmpl>
+  <mat-icon fontSet="far" fontIcon="fa-circle"></mat-icon>
+</ng-template>
+
+<!-- option template -->
+<ng-template
+  #optionTmpl
+  let-item
+  let-overridePrefixIconTmpl="overridePrefixIconTmpl"
+  let-overrideSuffixIcon="overrideSuffixIcon">
+
+  <!-- prefix -->
+  <ng-template [ngIf]="overridePrefixIconTmpl" [ngIfElse]="defaultPrefix">
+    <ng-template [ngTemplateOutlet]="overridePrefixIconTmpl"></ng-template>
+  </ng-template>
+  <ng-template #defaultPrefix>
+    <ng-template [ngIf]="selectedIds" let-selectedIds>
+      <mat-icon
+        fontSet="fas"
+        [fontIcon]="selectedIds.includes(item['@id']) ? 'fa-circle' : 'fa-none'"
+        >
+      </mat-icon>
+    </ng-template>
+  </ng-template>
+
+  <!-- button body -->
+  <span *ngIf="item; else noItemTmpl" class="full-sized-button">
+    {{ item.name || item.fullName }}
+  </span>
+
+  <!-- suffix -->
+  <ng-template [ngIf]="overrideSuffixIcon">
+    <i [class]="overrideSuffixIcon"></i>
+  </ng-template>
+</ng-template>
+
+
+<ng-template #isBusyTmpl>
+
+</ng-template>
\ No newline at end of file
diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/story.base.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/story.base.ts
new file mode 100644
index 000000000..86b1eae65
--- /dev/null
+++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/story.base.ts
@@ -0,0 +1,42 @@
+import { getAtlases, getParcellations, getSpaces } from "src/atlasComponents/sapi/stories.base"
+import { SapiAtlasModel, SapiParcellationModel, SapiSpaceModel } from "src/atlasComponents/sapi/type"
+
+export const wrapperDecoratorFn = (story: string) => `
+  <style>
+  .wrapper {
+    width: 100%;
+    height: 100%;
+    background-color: white;
+
+    display: flex;
+    flex-direction: column-reverse;
+    border: 1px solid rgba(128, 128, 128, 0.5);
+  }
+  :host-context([darktheme="true"]) .wrapper
+  {
+    background-color: #2f2f2f;
+  }
+  </style>
+  <div class="wrapper">${story}</div>
+`
+
+export type ReturnAtlas = {
+  atlas: SapiAtlasModel
+  spaces: SapiSpaceModel[]
+  parcs: SapiParcellationModel[]
+}
+
+export const loadAtlasEtcData = async () => {
+  const combinedAtlases: ReturnAtlas[] = []
+  const atlases = await getAtlases()
+  for (const atlas of atlases) {
+    const parcs = await getParcellations(atlas["@id"])
+    const spaces = await getSpaces(atlas["@id"])
+    combinedAtlases.push({
+      atlas,
+      parcs,
+      spaces
+    })
+  }
+  return { combinedAtlases, atlases }
+}
diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.component.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.component.ts
new file mode 100644
index 000000000..0b4bfe6c8
--- /dev/null
+++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.component.ts
@@ -0,0 +1,137 @@
+import { Component, OnDestroy } from "@angular/core";
+import { MatDialog } from "@angular/material/dialog";
+import { Store } from "@ngrx/store";
+import { Observable, of, Subject, Subscription } from "rxjs";
+import { filter, map, switchMap, tap, withLatestFrom } from "rxjs/operators";
+import { SAPI } from "src/atlasComponents/sapi/sapi.service";
+import { ParcellationSupportedInSpacePipe } from "src/atlasComponents/sapiViews/util/parcellationSupportedInSpace.pipe";
+import { atlasSelection } from "src/state";
+import { fromRootStore } from "src/state/atlasSelection";
+import { DialogFallbackCmp } from "src/ui/dialogInfo";
+import { ParcellationVisibilityService } from "../../../parcellation/parcellationVis.service";
+import { defaultColorPalette, ATP } from "../pureDumb/pureATPSelector.components"
+
+function isATPGuard(obj: any): obj is ATP {
+  if (!obj) return false
+  return obj.atlas || obj.template || obj.parcellation
+}
+
+@Component({
+  selector: 'sxplr-wrapper-atp-selector',
+  templateUrl: './wrapper.template.html',
+  styleUrls: [
+    `./wrapper.style.css`
+  ]
+})
+
+export class WrapperATPSelector implements OnDestroy{
+  defaultColorPalette = defaultColorPalette
+
+  #subscription: Subscription[] = []
+  #parcSupportedInSpacePipe = new ParcellationSupportedInSpacePipe(this.sapi)
+
+  #askUser(title: string, descMd: string): Observable<boolean> {
+    const agree = "OK"
+    return this.dialog.open(DialogFallbackCmp, {
+      data: {
+        title,
+        descMd,
+        actions: [agree]
+      }
+    }).afterClosed().pipe(
+      map(val => val === agree)
+    )
+  }
+
+  selectedATP$ = this.store$.pipe(
+    fromRootStore.distinctATP(),
+  )
+
+  allAtlases$ = this.sapi.atlases$
+  availableTemplates$ = this.store$.pipe(
+    fromRootStore.allAvailSpaces(this.sapi),
+  )
+  parcs$ = this.store$.pipe(
+    fromRootStore.allAvailParcs(this.sapi),
+  )
+  isBusy$ = new Subject<boolean>()
+  
+  parcellationVisibility$ = this.svc.visibility$
+
+  constructor(
+    private dialog: MatDialog,
+    private store$: Store,
+    private sapi: SAPI,
+    private svc: ParcellationVisibilityService,
+  ){
+    this.#subscription.push(
+      this.selectLeaf$.pipe(
+        tap(() => this.isBusy$.next(true)),
+        withLatestFrom(this.selectedATP$),
+        switchMap(([{ atlas, template, parcellation }, selectedATP]) => {
+          if (atlas) {
+            /**
+             * do not need to ask permission to switch atlas
+             */
+            return of({ atlas })
+          }
+          if (template) {
+            return this.#parcSupportedInSpacePipe.transform(selectedATP.parcellation, template).pipe(
+              switchMap(({ supported }) => supported
+                ? of({ template })
+                : this.#askUser(`Incompatible parcellation`, `Attempting to load template **${template.fullName}**, which does not support parcellation **${selectedATP.parcellation.name}**. Proceed anyway and load the default parcellation?`).pipe(
+                  switchMap(flag => of(flag ? { template } : null))
+                ))
+            )
+          }
+          if (parcellation) {
+            return this.#parcSupportedInSpacePipe.transform(parcellation, selectedATP.template).pipe(
+              switchMap(({ supported }) => supported
+                ? of({ parcellation })
+                : this.#askUser(`Incompatible template`, `Attempting to load parcellation **${parcellation.name}**, which is not supported in template **${selectedATP.template.fullName}**. Proceed anyway and load the default template?`).pipe(
+                  switchMap(flag => of(flag ? { parcellation } : null))
+                ))
+            )
+          }
+          return of(null)
+        }),
+        filter(val => {
+          this.isBusy$.next(false)
+          return !!val
+        })
+      ).subscribe((obj) => {
+
+        if (!isATPGuard(obj)) return
+        const { atlas, parcellation, template } = obj
+        if (atlas) {
+          this.store$.dispatch(
+            atlasSelection.actions.selectAtlas({ atlas })
+          )
+        }
+        if (parcellation) {
+          this.store$.dispatch(
+            atlasSelection.actions.selectParcellation({ parcellation })
+          )
+        }
+        if (template) {
+          this.store$.dispatch(
+            atlasSelection.actions.selectTemplate({ template })
+          )
+        }
+      })
+    )
+  }
+
+  private selectLeaf$ = new Subject<ATP>()
+  selectLeaf(atp: ATP) {
+    this.selectLeaf$.next(atp)
+  }
+
+  toggleParcellationVisibility() {
+    this.svc.toggleVisibility()
+  }
+
+  ngOnDestroy(): void {
+    while (this.#subscription.length > 0) this.#subscription.pop().unsubscribe()
+  }
+}
diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.stories.ts b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.stories.ts
new file mode 100644
index 000000000..8dbf60965
--- /dev/null
+++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.stories.ts
@@ -0,0 +1,81 @@
+import { Component } from "@angular/core";
+import { MatDialogModule } from "@angular/material/dialog";
+import { Store } from "@ngrx/store";
+import { action } from "@storybook/addon-actions";
+import { componentWrapperDecorator, Meta, moduleMetadata, Story } from "@storybook/angular";
+import { atlasId, provideDarkTheme } from "src/atlasComponents/sapi/stories.base";
+import { atlasSelection, RootStoreModule } from "src/state";
+import { ParcellationVisibilityService } from "../../../parcellation/parcellationVis.service";
+import { ATPSelectorModule } from "../module";
+import { ATP } from "../pureDumb/pureATPSelector.components";
+import { loadAtlasEtcData, wrapperDecoratorFn } from "../story.base";
+
+@Component({
+  selector: 'wrapper-wrapper',
+  template: '<sxplr-wrapper-atp-selector></sxplr-wrapper-atp-selector>'
+})
+class Wrapper{
+  set ATP(atp: ATP) {
+    const { atlas, parcellation, template } = atp
+    
+    this.store$.dispatch(
+      atlasSelection.actions.setAtlasSelectionState({
+        selectedAtlas: atlas,
+        selectedTemplate: template,
+        selectedParcellation: parcellation
+      })
+    )
+
+    this.store$.dispatch = action('dispatch')
+  }
+
+  constructor(private store$: Store) {}
+}
+
+export default {
+  component: Wrapper,
+  decorators: [
+    moduleMetadata({
+      imports: [
+        ATPSelectorModule,
+        MatDialogModule,
+        RootStoreModule,
+      ],
+      providers: [
+        ...provideDarkTheme,
+        ParcellationVisibilityService,
+      ]
+    }),
+    componentWrapperDecorator(wrapperDecoratorFn)
+  ]
+} as Meta
+
+const Template: Story<Wrapper> = (args: Wrapper, { loaded }) => {
+  const { combinedAtlas } = loaded
+  return {
+    props: {
+      ...args,
+      ATP: {
+        atlas: combinedAtlas.atlas,
+        parcellation: combinedAtlas.parcs[0],
+        template: combinedAtlas.spaces[0],
+      }
+    }
+  }
+}
+
+export const Default = Template.bind({})
+
+Default.loaders = [
+  async () => {
+    const {
+      atlases,
+      combinedAtlases
+    } = await loadAtlasEtcData()
+    
+    return {
+      atlases,
+      combinedAtlas: combinedAtlases.filter(v => v.atlas["@id"] === atlasId.human)[0]
+    }
+  }
+]
diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.style.css b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.style.css
new file mode 100644
index 000000000..a738cc567
--- /dev/null
+++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.style.css
@@ -0,0 +1,5 @@
+[parcellation-chip-suffix]
+{
+  margin-right: -1rem;
+  margin-left: 0.2rem;
+}
diff --git a/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.template.html b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.template.html
new file mode 100644
index 000000000..52b49096e
--- /dev/null
+++ b/src/atlasComponents/sapiViews/core/rich/ATPSelector/wrapper/wrapper.template.html
@@ -0,0 +1,18 @@
+<sxplr-pure-atp-selector
+  [sxplr-pure-atp-selector-color-palette]="defaultColorPalette"
+  [sxplr-pure-atp-selector-selected-atp]="selectedATP$ | async"
+  [sxplr-pure-atp-selector-atlases]="allAtlases$ | async"
+  [sxplr-pure-atp-selector-templates]="availableTemplates$ | async"
+  [sxplr-pure-atp-selector-parcellations]="parcs$ | async"
+  [sxplr-pure-atp-selector-is-busy]="isBusy$ | async"
+  (sxplr-pure-atp-selector-on-select)="selectLeaf($event)"
+  >
+  <button mat-icon-button
+    (click)="toggleParcellationVisibility()"
+    parcellation-chip-suffix
+    iav-stop="mousedown click"
+    [iav-key-listener]="[{'type': 'keydown', 'key': 'q', 'capture': true, 'target': 'document' }]"
+    (iav-key-event)="toggleParcellationVisibility()" >
+    <i class="fas" [ngClass]="(parcellationVisibility$ | async) ? 'fa-eye' : 'fa-eye-slash'"></i>
+  </button>
+</sxplr-pure-atp-selector>
diff --git a/src/components/smartChip/component/smartChip.component.ts b/src/components/smartChip/component/smartChip.component.ts
new file mode 100644
index 000000000..d076f2ddb
--- /dev/null
+++ b/src/components/smartChip/component/smartChip.component.ts
@@ -0,0 +1,88 @@
+import { ChangeDetectionStrategy, Component, ContentChild, EventEmitter, HostBinding, Input, OnChanges, Output, SimpleChanges } from "@angular/core";
+import { SmartChipContent } from "../smartChip.content.directive"
+import { SmartChipMenu } from "../smartChip.menu.directive";
+import { rgbToHsl, hexToRgb } from 'common/util'
+
+const cssColorIsDark = (input: string) => {
+  if (/rgb/i.test(input)) {
+    const match = /\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(input)
+    if (!match) throw new Error(`rgb cannot be extracted ${input}`)
+    const rgb = [
+      parseInt(match[1]),
+      parseInt(match[2]),
+      parseInt(match[3]),
+    ]
+    const [_h, _s, l] = rgbToHsl(...rgb)
+    return l < 0.4
+  }
+  if (/hsl/i.test(input)) {
+    const match = /\((.*)\)/.exec(input)
+    const [h, s, l] = match[1].split(",")
+    const trimmedL = l.trim()
+    if (/\%$/.test(trimmedL)) {
+      const match = /^([0-9]+)\%/.exec(trimmedL)
+      return (parseInt(match[1]) / 100) < 0.4
+    }
+  }
+  if (/^#/i.test(input) && input.length === 7) {
+    const [r, g, b] = hexToRgb(input)
+    const [_h, _s, l] = rgbToHsl(r, g, b)
+    return l < 0.6
+  }
+  throw new Error(`Cannot parse css color: ${input}`)
+}
+
+@Component({
+  selector: `sxplr-smart-chip`,
+  templateUrl: `./smartChip.template.html`,
+  styleUrls: [
+    `/smartChip.style.css`
+  ],
+  changeDetection: ChangeDetectionStrategy.OnPush
+})
+
+export class SmartChip<T extends object> implements OnChanges{
+  @Input('color')
+  color = `rgba(200, 200, 200, 1)`
+
+  @Input('disabled')
+  disabled: boolean = false
+
+  @Input('elevation')
+  elevation: number = 4 // translates to mat-elevation-z{elevation}
+
+  smartChipClass = 'mat-elevation-z4'
+
+  @Input('items')
+  items: T[] = []
+
+  @Input('getChildren')
+  getChildren: (item: T) => T[] = item => item['children'] || []
+
+  @Output('itemClicked')
+  itemClicked = new EventEmitter<T>()
+
+  @ContentChild(SmartChipContent)
+  contentTmpl: SmartChipContent
+
+  @ContentChild(SmartChipMenu)
+  menuTmpl: SmartChipMenu
+
+  @HostBinding('class')
+  darkTheme: string = 'lighttheme'
+
+  ngOnChanges(simpleChanges: SimpleChanges) {
+    if (simpleChanges.color) {
+      this.darkTheme = cssColorIsDark(this.color)
+        ? 'darktheme'
+        : 'lighttheme'
+    }
+
+    if (simpleChanges.disabled || simpleChanges.elevation) {
+      this.smartChipClass = [
+        this.disabled ? 'disabled' : null,
+        `mat-elevation-z${this.elevation}`
+      ].filter(v => !!v).join(' ')
+    }
+  }
+}
diff --git a/src/components/smartChip/component/smartChip.style.css b/src/components/smartChip/component/smartChip.style.css
new file mode 100644
index 000000000..412628c32
--- /dev/null
+++ b/src/components/smartChip/component/smartChip.style.css
@@ -0,0 +1,34 @@
+.smart-chip
+{
+  display: inline-block;
+  padding: 0.5rem 1rem;
+  margin: 0.5rem 1rem;
+  border-radius: 1rem;
+  height: 1.25rem;
+
+  display: inline-flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.smart-chip
+{
+  opacity: 1.0;
+  transition: opacity 160ms ease-in-out;
+}
+
+.smart-chip:not(.disabled):hover
+{
+  cursor: default;
+}
+
+.smart-chip.disabled
+{
+  opacity: 0.5;
+  pointer-events: none;
+}
+
+.smart-chip.disabled:hover
+{
+  cursor: not-allowed;
+}
diff --git a/src/components/smartChip/component/smartChip.template.html b/src/components/smartChip/component/smartChip.template.html
new file mode 100644
index 000000000..c82394e1d
--- /dev/null
+++ b/src/components/smartChip/component/smartChip.template.html
@@ -0,0 +1,61 @@
+<div [style.background-color]="color"
+  [matMenuTriggerFor]="mainMenu"
+  matRipple
+  [ngClass]="smartChipClass"
+  class="mat-body smart-chip sxplr-custom-cmp text">
+  <ng-template [ngTemplateOutlet]="contentTmpl?.templateRef || fallbackContentTmpl">
+  </ng-template>
+</div>
+
+<!-- main menu is fired from chip -->
+<mat-menu #mainMenu="matMenu">
+  <ng-template ngFor [ngForOf]="items" let-item>
+
+    <!-- if item is has submenu -->
+    <ng-template [ngIf]="item | hasSubMenu : getChildren" [ngIfElse]="noSubMenuTmpl">
+      <button
+        mat-menu-item
+        [matMenuTriggerFor]="subMenu"
+        [matMenuTriggerData]="{ $implicit: getChildren(item) }">
+        <ng-container *ngTemplateOutlet="menuTmpl?.templateRef || fallbackMenu; context: {
+          $implicit: item
+        }">
+        </ng-container>
+      </button>
+
+    </ng-template>
+
+    <!-- if item has no submenu -->
+    <ng-template #noSubMenuTmpl>
+      <ng-container *ngTemplateOutlet="leafTmpl; context: { $implicit: item }"></ng-container>
+    </ng-template>
+
+  </ng-template>
+</mat-menu>
+
+<!-- submenu (fired from menu item) -->
+<mat-menu #subMenu="matMenu">
+  <ng-template matMenuContent let-items>
+    <ng-template ngFor [ngForOf]="items" let-item>
+      <ng-container *ngTemplateOutlet="leafTmpl; context: { $implicit: item }"></ng-container>
+    </ng-template>
+  </ng-template>
+</mat-menu>
+
+<!-- template to render the leaf nodes -->
+<ng-template #leafTmpl let-item>
+  <button mat-menu-item (click)="itemClicked.emit(item)">
+    <ng-container *ngTemplateOutlet="menuTmpl?.templateRef || fallbackMenu; context: {
+      $implicit: item
+    }">
+    </ng-container>
+  </button>
+</ng-template>
+
+<ng-template #fallbackContentTmpl>
+  Fallback Content
+</ng-template>
+
+<ng-template #fallbackMenu let-item>
+  {{ item.name || 'Item Name' }}
+</ng-template>
diff --git a/src/components/smartChip/hasSubmenu.pipe.ts b/src/components/smartChip/hasSubmenu.pipe.ts
new file mode 100644
index 000000000..32553eb63
--- /dev/null
+++ b/src/components/smartChip/hasSubmenu.pipe.ts
@@ -0,0 +1,12 @@
+import { Pipe, PipeTransform } from "@angular/core";
+
+@Pipe({
+  name: 'hasSubMenu',
+  pure: true
+})
+
+export class HasSubMenuPipe<T extends object> implements PipeTransform{
+  public transform(item: T, getChildren: (obj: T) => T[]): boolean {
+    return (getChildren(item) || []).length > 0
+  }
+}
diff --git a/src/components/smartChip/index.ts b/src/components/smartChip/index.ts
new file mode 100644
index 000000000..fd61c8404
--- /dev/null
+++ b/src/components/smartChip/index.ts
@@ -0,0 +1,3 @@
+export { SmartChipModule } from "./module"
+export { SmartChip } from "./component/smartChip.component"
+export { SmartChipContent } from "./smartChip.content.directive"
diff --git a/src/components/smartChip/module.ts b/src/components/smartChip/module.ts
new file mode 100644
index 000000000..00fe423cc
--- /dev/null
+++ b/src/components/smartChip/module.ts
@@ -0,0 +1,31 @@
+import { CommonModule } from "@angular/common";
+import { NgModule } from "@angular/core";
+import { MatRippleModule } from "@angular/material/core";
+import { MatMenuModule } from "@angular/material/menu";
+import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
+import { SmartChip } from "./component/smartChip.component";
+import { HasSubMenuPipe } from "./hasSubmenu.pipe";
+import { SmartChipContent } from "./smartChip.content.directive";
+import { SmartChipMenu } from "./smartChip.menu.directive";
+
+@NgModule({
+  imports: [
+    CommonModule,
+    MatMenuModule,
+    BrowserAnimationsModule,
+    MatRippleModule,
+  ],
+  declarations: [
+    SmartChipMenu,
+    SmartChipContent,
+    SmartChip,
+    HasSubMenuPipe,
+  ],
+  exports: [
+    SmartChipMenu,
+    SmartChipContent,
+    SmartChip,
+  ]
+})
+
+export class SmartChipModule{}
diff --git a/src/components/smartChip/smartChip.content.directive.ts b/src/components/smartChip/smartChip.content.directive.ts
new file mode 100644
index 000000000..4026fdf61
--- /dev/null
+++ b/src/components/smartChip/smartChip.content.directive.ts
@@ -0,0 +1,9 @@
+import { Directive, Inject, TemplateRef } from "@angular/core";
+
+@Directive({
+  selector: `ng-template[sxplrSmartChipContent]`
+})
+
+export class SmartChipContent {
+  constructor(@Inject(TemplateRef) public templateRef: TemplateRef<unknown>){}  
+}
diff --git a/src/components/smartChip/smartChip.menu.directive.ts b/src/components/smartChip/smartChip.menu.directive.ts
new file mode 100644
index 000000000..138e5fdc0
--- /dev/null
+++ b/src/components/smartChip/smartChip.menu.directive.ts
@@ -0,0 +1,9 @@
+import { Directive, Inject, TemplateRef } from "@angular/core";
+
+@Directive({
+  selector: `ng-template[sxplrSmartChipMenu]`
+})
+
+export class SmartChipMenu {
+  constructor(@Inject(TemplateRef) public templateRef: TemplateRef<unknown>){}  
+}
diff --git a/src/components/smartChip/smartChip.stories.ts b/src/components/smartChip/smartChip.stories.ts
new file mode 100644
index 000000000..791c9372e
--- /dev/null
+++ b/src/components/smartChip/smartChip.stories.ts
@@ -0,0 +1,160 @@
+import { CommonModule } from "@angular/common";
+import { Component, Pipe, PipeTransform } from "@angular/core";
+import { MatDividerModule } from "@angular/material/divider";
+import { MatExpansionModule } from "@angular/material/expansion";
+import { Meta, moduleMetadata, Story } from "@storybook/angular";
+import { provideDarkTheme } from "src/atlasComponents/sapi/stories.base";
+import { SmartChipModule } from "./module";
+
+
+const complex1 = `
+<sxplr-smart-chip [color]="color" [items]="inventory">
+  <span *sxplrSmartChipContent>menu example</span>
+  <ng-template sxplrSmartChipMenu let-inv>
+    {{ inv.name }}
+  </ng-template>
+</sxplr-smart-chip>
+`
+
+const complex2 = `
+
+<sxplr-smart-chip [color]="color" [items]="categories" [getChildren]="getChildren">
+  <span *sxplrSmartChipContent>submenu example</span>
+  <ng-template sxplrSmartChipMenu let-item>
+    {{ item.categoryName || item.name }}
+  </ng-template>
+</sxplr-smart-chip>
+`
+
+const input = {
+  complex1,
+  complex2,
+}
+
+const getHtmlSnippet = (key: string) => `
+<mat-expansion-panel>
+<mat-expansion-panel-header>
+  <mat-panel-title>
+    Code
+  </mat-panel-title>
+</mat-expansion-panel-header>
+<pre>
+  <code [innerHTML]="input.${key} | showHtmlCode">
+  </code>
+</pre>
+</mat-expansion-panel>
+${input[key]}
+`
+
+@Pipe({
+  name: 'showHtmlCode',
+  pure: true
+})
+class ShowHtmlCode implements PipeTransform {
+  public transform(input: string) {
+    return input
+      .replace(/&/g, "&amp;")
+      .replace(/</g, "&lt;")
+      .replace(/>/g, "&gt;")
+      .replace(/"/g, "&quot;")
+      .replace(/'/g, "&#039;")
+  }
+}
+
+type Inventory = {
+  name: string
+  quantity: number
+}
+
+type Category = {
+  categoryName: string
+  inventories: Inventory[]
+}
+
+@Component({
+  selector: 'smart-chip-container',
+  template: `
+  <div class="container">
+    ${Object.keys(input).map(key => getHtmlSnippet(key)).join('\n<mat-divider></mat-divider>\n')}
+  </div>
+  `,
+  styles: [
+    `.mat-divider{ margin: 1rem; } .container{ display: flex; flex-direction: column; }`
+  ]
+})
+class SmartChipContainerCmp{
+  color:string
+
+  inventory: Inventory[] = [{
+    name: 'banana',
+    quantity: 10,
+  }, {
+    name: 'apple',
+    quantity: 5,
+  }]
+
+  categories: Category[] = [{
+    categoryName: 'fruits',
+    inventories: this.inventory
+  }, {
+    categoryName: 'processed food',
+    inventories: [{
+      name: 'pizza',
+      quantity: 1000,
+    }, {
+      name: 'kebab',
+      quantity: 12
+    }]
+  }, {
+    categoryName: 'xmas food',
+    inventories: []
+  }]
+
+  getChildren(category: Category) {
+    return category.inventories
+  }
+  input = input
+}
+
+export default {
+  component: SmartChipContainerCmp,
+  decorators: [
+    moduleMetadata({
+      imports: [
+        CommonModule,
+        SmartChipModule,
+        MatExpansionModule,
+        MatDividerModule,
+      ],
+      declarations: [
+        ShowHtmlCode,
+      ],
+      providers: [
+        ...provideDarkTheme,
+      ]
+    })
+  ]
+} as Meta
+
+const Template: Story<SmartChipContainerCmp> = (args: SmartChipContainerCmp) => {
+  const { color } = args
+  return ({
+    props: {
+      color
+    }
+  })
+}
+
+export const Default = Template.bind({})
+Default.args = {
+  color: 'rgba(206, 221, 247, 1)',
+}
+
+
+// export const AtlasEtc = Template.bind({})
+// AtlasEtc.args = {
+//   color: 'rgba(206, 221, 247, 1)'
+// }
+// AtlasEtc.loaders = [
+//   loadAtlasEtcData
+// ]
diff --git a/src/state/atlasSelection/util.ts b/src/state/atlasSelection/util.ts
index cf64466bb..2ee06893c 100644
--- a/src/state/atlasSelection/util.ts
+++ b/src/state/atlasSelection/util.ts
@@ -1,32 +1,42 @@
 import { createSelector, select } from "@ngrx/store";
-import { forkJoin, pipe } from "rxjs";
+import { forkJoin, of, pipe } from "rxjs";
 import { distinctUntilChanged, map, switchMap } from "rxjs/operators";
 import { SAPI, SapiAtlasModel, SapiParcellationModel, SapiSpaceModel } from "src/atlasComponents/sapi";
 import { jsonEqual } from "src/util/json";
 import * as selectors from "./selectors"
 
-const allAvailSpaces = (sapi: SAPI) => pipe(
-  select(selectors.selectedAtlas),
-  switchMap(atlas => forkJoin(
-    atlas.spaces.map(spcWId => sapi.getSpaceDetail(atlas["@id"], spcWId["@id"])))
+const allAvailSpaces = (sapi: SAPI) => {
+  return pipe(
+    select(selectors.selectedAtlas),
+    switchMap(atlas => atlas
+      ? forkJoin(
+        atlas.spaces.map(spcWId => sapi.getSpaceDetail(atlas["@id"], spcWId["@id"]))
+      )
+      : of([])
+    )
   )
-)
+}
 
 const allAvailParcs = (sapi: SAPI) => pipe(
   select(selectors.selectedAtlas),
-  switchMap(atlas =>
-    forkJoin(
+  switchMap(atlas => atlas
+    ? forkJoin(
       atlas.parcellations.map(parcWId => sapi.getParcDetail(atlas["@id"], parcWId["@id"]))
     )
+    : of([])
   )
 )
 const allAvailSpacesParcs = (sapi: SAPI) => pipe(
   select(selectors.selectedAtlas),
-  switchMap(atlas =>
-    forkJoin({
+  switchMap(atlas => atlas
+    ? forkJoin({
       spaces: atlas.spaces.map(spcWId => sapi.getSpaceDetail(atlas["@id"], spcWId["@id"])),
       parcellation: atlas.parcellations.map(parcWId => sapi.getParcDetail(atlas["@id"], parcWId["@id"])),
     })
+    : of({
+      spaces: [],
+      parcellation: []
+    })
   )
 )
 
diff --git a/src/ui/dialogInfo/dialog.directive.ts b/src/ui/dialogInfo/dialog.directive.ts
index 63d886a6e..eccb689f2 100644
--- a/src/ui/dialogInfo/dialog.directive.ts
+++ b/src/ui/dialogInfo/dialog.directive.ts
@@ -1,6 +1,6 @@
 import { Directive, HostListener, Input, TemplateRef } from "@angular/core";
 import { MatDialog, MatDialogConfig } from "@angular/material/dialog";
-import { MatSnackBar } from "@angular/material/snack-bar";
+import { DialogFallbackCmp } from "./tmpl/tmpl.component"
 
 type DialogSize = 's' | 'm' | 'l' | 'xl'
 
@@ -39,20 +39,13 @@ export class DialogDirective{
   @Input('sxplr-dialog-data')
   data: unknown
 
-  constructor(
-    private matDialog: MatDialog,
-    private snackbar: MatSnackBar,
-  ){
-  }
+  constructor(private matDialog: MatDialog){}
 
   @HostListener('click')
   onClick(){
-    if (!this.templateRef) {
-      return this.snackbar.open(`Cannot show dialog. sxplr-dialog template not provided`)
-    }
-    this.matDialog.open(this.templateRef, {
+    this.matDialog.open(this.templateRef || DialogFallbackCmp, {
       data: this.data,
       ...(sizeDict[this.size] || {})
     })
   }
-}
\ No newline at end of file
+}
diff --git a/src/ui/dialogInfo/index.ts b/src/ui/dialogInfo/index.ts
index 354dde0cb..8eae65c02 100644
--- a/src/ui/dialogInfo/index.ts
+++ b/src/ui/dialogInfo/index.ts
@@ -1 +1,3 @@
-export { DialogDirective } from "./dialog.directive"
\ No newline at end of file
+export { DialogDirective } from "./dialog.directive"
+export { DialogModule } from "./module"
+export { DialogFallbackCmp } from "./tmpl/tmpl.component"
diff --git a/src/ui/dialogInfo/module.ts b/src/ui/dialogInfo/module.ts
index de09c12a6..82702efff 100644
--- a/src/ui/dialogInfo/module.ts
+++ b/src/ui/dialogInfo/module.ts
@@ -1,15 +1,23 @@
+import { CommonModule } from "@angular/common";
 import { NgModule } from "@angular/core";
+import { MatButtonModule } from "@angular/material/button";
 import { MatDialogModule } from "@angular/material/dialog";
-import { MatSnackBarModule } from "@angular/material/snack-bar";
+import { MarkdownModule } from "src/components/markdown";
+import { StrictLocalModule } from "src/strictLocal";
 import { DialogDirective } from "./dialog.directive"
+import { DialogFallbackCmp } from "./tmpl/tmpl.component";
 
 @NgModule({
   imports: [
-    MatSnackBarModule,
+    CommonModule,
     MatDialogModule,
+    MatButtonModule,
+    MarkdownModule,
+    StrictLocalModule,
   ],
   declarations: [
     DialogDirective,
+    DialogFallbackCmp,
   ],
   exports: [
     DialogDirective,
diff --git a/src/ui/dialogInfo/tmpl/tmpl.component.ts b/src/ui/dialogInfo/tmpl/tmpl.component.ts
new file mode 100644
index 000000000..f5e2f505d
--- /dev/null
+++ b/src/ui/dialogInfo/tmpl/tmpl.component.ts
@@ -0,0 +1,25 @@
+import { Component, Inject } from "@angular/core";
+import { MAT_DIALOG_DATA } from "@angular/material/dialog";
+
+export type FallBackData = {
+  title: string
+  actions?: string[]
+  desc?: string
+  descMd?: string
+}
+
+@Component({
+  selector: 'sxplr-dialog-fallback-tmpl',
+  templateUrl: './tmpl.template.html',
+  styleUrls: [
+    './tmpl.style.css'
+  ],
+})
+
+export class DialogFallbackCmp {
+  constructor(
+    @Inject(MAT_DIALOG_DATA) public data: FallBackData
+  ){
+
+  }
+}
diff --git a/src/ui/dialogInfo/tmpl/tmpl.stories.ts b/src/ui/dialogInfo/tmpl/tmpl.stories.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/ui/dialogInfo/tmpl/tmpl.style.css b/src/ui/dialogInfo/tmpl/tmpl.style.css
new file mode 100644
index 000000000..63e67f07f
--- /dev/null
+++ b/src/ui/dialogInfo/tmpl/tmpl.style.css
@@ -0,0 +1,4 @@
+mat-dialog-actions a i
+{
+  margin-right: 0.5rem;
+}
diff --git a/src/ui/dialogInfo/tmpl/tmpl.template.html b/src/ui/dialogInfo/tmpl/tmpl.template.html
new file mode 100644
index 000000000..31807ec66
--- /dev/null
+++ b/src/ui/dialogInfo/tmpl/tmpl.template.html
@@ -0,0 +1,40 @@
+<h1 *ngIf="data.title" mat-dialog-title>
+  {{ data.title }}
+</h1>
+
+<div *ngIf="data.descMd || data.desc" mat-dialog-content class="mat-body">
+  <ng-template [ngIf]="data.desc">
+    {{ data.desc }}
+  </ng-template>
+  
+  <markdown-dom *ngIf="data.descMd" [markdown]="data.descMd">
+  </markdown-dom>
+</div>
+
+
+<mat-dialog-actions align="start">
+
+  <ng-template ngFor [ngForOf]="data.actions || []" let-action>
+    <a *ngIf="action.startsWith('http'); else defaultActionTmpl"
+      [href]="action"
+      sxplr-hide-when-local
+      target="_blank"
+      mat-raised-button
+      color="primary">
+      <i class="fas fa-external-link-alt"></i>
+      <span>Detail</span>
+    </a>
+
+    <ng-template #defaultActionTmpl>
+      <button mat-raised-button
+        color="primary"
+        [mat-dialog-close]="action">
+        {{ action }}
+      </button>
+    </ng-template>
+  </ng-template>
+
+  <button mat-dialog-close mat-button>
+    Close
+  </button>
+</mat-dialog-actions>
diff --git a/src/util/recursivePartial.ts b/src/util/recursivePartial.ts
new file mode 100644
index 000000000..6056208de
--- /dev/null
+++ b/src/util/recursivePartial.ts
@@ -0,0 +1,3 @@
+export type RecursivePartial<T> = Partial<{
+  [K in keyof T]: RecursivePartial<T[K]>
+}>
-- 
GitLab