diff --git a/angular.json b/angular.json
index 8e13f26..3a02574 100644
--- a/angular.json
+++ b/angular.json
@@ -25,7 +25,8 @@
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
- "scripts": []
+ "scripts": [],
+ "allowedCommonJsDependencies": ["papaparse"]
},
"configurations": {
"production": {
diff --git a/package-lock.json b/package-lock.json
index f192f20..52ed1e6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,8 @@
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
+ "@fortawesome/angular-fontawesome": "^0.14.1",
+ "@fortawesome/free-solid-svg-icons": "^6.6.0",
"chart.js": "^4.4.3",
"papaparse": "^5.4.1",
"rxjs": "~7.8.0",
@@ -2683,6 +2685,53 @@
"node": ">=12"
}
},
+ "node_modules/@fortawesome/angular-fontawesome": {
+ "version": "0.14.1",
+ "resolved": "https://registry.npmjs.org/@fortawesome/angular-fontawesome/-/angular-fontawesome-0.14.1.tgz",
+ "integrity": "sha512-Yb5HLiEOAxjSLEcaOM51CKIrzdfvoDafXVJERm9vufxfZkVZPZJgrZRgqwLVpejgq4/Ez6TqHZ6SqmJwdtRF6g==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "peerDependencies": {
+ "@angular/core": "^17.0.0",
+ "@fortawesome/fontawesome-svg-core": "~1.2.27 || ~1.3.0-beta2 || ^6.1.0"
+ }
+ },
+ "node_modules/@fortawesome/fontawesome-common-types": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz",
+ "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@fortawesome/fontawesome-svg-core": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz",
+ "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@fortawesome/fontawesome-common-types": "6.6.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@fortawesome/free-solid-svg-icons": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz",
+ "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==",
+ "license": "(CC-BY-4.0 AND MIT)",
+ "dependencies": {
+ "@fortawesome/fontawesome-common-types": "6.6.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
diff --git a/package.json b/package.json
index 054e505..5acbc55 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,8 @@
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
+ "@fortawesome/angular-fontawesome": "^0.14.1",
+ "@fortawesome/free-solid-svg-icons": "^6.6.0",
"chart.js": "^4.4.3",
"papaparse": "^5.4.1",
"rxjs": "~7.8.0",
diff --git a/src/app/app.component.html b/src/app/app.component.html
index 86b775a..f8e15b6 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -16,9 +16,17 @@
[school]="school"
> -->
-
+> -->
+
+
+
+
+
diff --git a/src/app/app.component.scss b/src/app/app.component.scss
index 138aee3..d404b2a 100644
--- a/src/app/app.component.scss
+++ b/src/app/app.component.scss
@@ -1,9 +1,29 @@
:host {
display: flex;
- flex-direction: column;
+ flex-direction: row;
}
div {
+ flex-direction: column;
+ display: flex;
+}
+
+.menu {
+ width: 200px;
+ height: calc(100vh - 16px);
+}
+
+.query {
+ height: 200px;
+ overflow: hidden;
+}
+
+.metadata {
+ flex: 1;
+ overflow: hidden;
+}
+
+.content {
flex: 1;
flex-direction: row;
display: flex;
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index b125ba8..ccb3b4c 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -4,7 +4,7 @@ import { ChartComponent } from '../components/chart/chart.component';
import { SelectComponent } from '../components/select/select.component';
import { DataService } from '../services/data.service';
import { TableComponent } from '../components/table/table.component';
-import { Header } from '../components/table/header';
+import { Header } from '../models/header';
import { FilterService } from '../services/filters.service';
import { Observable } from 'rxjs';
import { Milestone } from '../enums/milestone';
@@ -12,6 +12,9 @@ import { ScoresComponent } from './scores/scores.component';
import { CommonModule } from '@angular/common';
import { CohortSectionComponent } from './cohort-section/cohort-section.component';
import { KeyValue } from '../models/key-value';
+import { QueryComponent } from '../components/query/query.component';
+import { MetadataComponent } from '../components/metadata/metadata.component';
+import { ResultComponent } from '../components/result/result.component';
@Component({
selector: 'app-root',
@@ -24,6 +27,9 @@ import { KeyValue } from '../models/key-value';
TableComponent,
ScoresComponent,
CohortSectionComponent,
+ QueryComponent,
+ MetadataComponent,
+ ResultComponent,
],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
@@ -43,10 +49,10 @@ export class AppComponent implements OnInit {
{ label: 'School', source: Milestone.School },
{ label: 'Grade', source: Milestone.Grade },
].map((data) => new Header(data));
- rows: Observable[]> = this.data.Data;
+ rows: Observable[]> = this.data.Data$;
ngOnInit(): void {
- this.data.Cohorts.subscribe((kv: KeyValue[]) => {
+ this.data.Cohorts$.subscribe((kv: KeyValue[]) => {
this.cohorts = kv.map((i: KeyValue) => i.key);
});
// this.filter.filters$.subscribe((filters: KeyValue[]) => {
diff --git a/src/app/cohort-section/cohort-section.component.html b/src/app/cohort-section/cohort-section.component.html
index a09db69..e1a19de 100644
--- a/src/app/cohort-section/cohort-section.component.html
+++ b/src/app/cohort-section/cohort-section.component.html
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/src/app/cohort-section/cohort-section.component.scss b/src/app/cohort-section/cohort-section.component.scss
index e69de29..9bb237f 100644
--- a/src/app/cohort-section/cohort-section.component.scss
+++ b/src/app/cohort-section/cohort-section.component.scss
@@ -0,0 +1,4 @@
+.chart {
+ height: 400px;
+ width: 400px;
+}
diff --git a/src/app/cohort-section/cohort-section.component.ts b/src/app/cohort-section/cohort-section.component.ts
index a295e84..20d7f92 100644
--- a/src/app/cohort-section/cohort-section.component.ts
+++ b/src/app/cohort-section/cohort-section.component.ts
@@ -8,6 +8,7 @@ import {
} from '../../components/chart/chart-type';
import { ChartConfig } from '../../components/chart/chart-config';
import { DataService } from '../../services/data.service';
+import { KeyValue } from '../../models/key-value';
@Component({
selector: 'fbi-cohort-section',
@@ -63,7 +64,14 @@ export class CohortSectionComponent {
Array.isArray(this.cohort) ? this.cohort : [this.cohort]
).map((v: string) => parseInt(v));
- this.dataService.Data.subscribe((data: Record[]) => {
+ this.dataService.Cohorts$.subscribe(
+ (cohorts: KeyValue[]) =>
+ (this.chart.title = cohorts
+ .map((cohort: KeyValue) => cohort.value)
+ .join(', '))
+ );
+
+ this.dataService.Data$.subscribe((data: Record[]) => {
this.data = data
.filter(
(d: Record) =>
diff --git a/src/app/scores/scores.component.ts b/src/app/scores/scores.component.ts
index 0449387..2e2e336 100644
--- a/src/app/scores/scores.component.ts
+++ b/src/app/scores/scores.component.ts
@@ -80,7 +80,7 @@ export class ScoresComponent implements OnInit {
const school = this.school;
const grade = this.grade;
- this.dataService.Data.subscribe((data: Record[]) => {
+ this.dataService.Data$.subscribe((data: Record[]) => {
this.data = data.filter(
(d: Record) =>
d[Milestone.County] === county &&
diff --git a/src/components/chart/chart-config.ts b/src/components/chart/chart-config.ts
index fa35301..b0c0b12 100644
--- a/src/components/chart/chart-config.ts
+++ b/src/components/chart/chart-config.ts
@@ -3,5 +3,6 @@ import { ChartAxis } from './chart-type';
export interface ChartConfig {
type: ChartType;
+ title?: string;
axis: ChartAxis[];
}
diff --git a/src/components/chart/chart-type.ts b/src/components/chart/chart-type.ts
index fa550bf..e9a467f 100644
--- a/src/components/chart/chart-type.ts
+++ b/src/components/chart/chart-type.ts
@@ -32,6 +32,7 @@ export interface IDataset {
xAxisKey?: string;
yAxisKey?: string;
};
+ spanGaps?: boolean;
stack?: string;
}
diff --git a/src/components/chart/chart.component.html b/src/components/chart/chart.component.html
index cce8f26..7739e1c 100644
--- a/src/components/chart/chart.component.html
+++ b/src/components/chart/chart.component.html
@@ -1 +1 @@
-
+
diff --git a/src/components/chart/chart.component.scss b/src/components/chart/chart.component.scss
index e69de29..85ae5ce 100644
--- a/src/components/chart/chart.component.scss
+++ b/src/components/chart/chart.component.scss
@@ -0,0 +1,4 @@
+.fit {
+ height: 100%;
+ width: 100%;
+}
diff --git a/src/components/chart/services/dataset.service.ts b/src/components/chart/services/dataset.service.ts
index e076972..b2929c7 100644
--- a/src/components/chart/services/dataset.service.ts
+++ b/src/components/chart/services/dataset.service.ts
@@ -46,6 +46,7 @@ export class DatasetService {
if (axis.fill) {
entry.fill = axis.fill;
}
+ entry.spanGaps = true;
result.push(entry);
});
diff --git a/src/components/chart/services/options.service.ts b/src/components/chart/services/options.service.ts
index 8659401..f03139f 100644
--- a/src/components/chart/services/options.service.ts
+++ b/src/components/chart/services/options.service.ts
@@ -15,11 +15,18 @@ export class OptionsService {
result.maintainAspectRatio = false;
result.scales = this.scaleService.scales(chart, data);
+ if (chart.title) {
+ result.plugins = result.plugins ?? {};
+ result.plugins.title = result.plugins.title ?? {};
+ result.plugins.title.text = chart.title;
+ }
+
const fill =
(chart?.axis ?? []).filter((axis: ChartAxis) => axis.fill).length > 0;
if (fill) {
result.plugins = result.plugins ?? {};
- result.plugins.filler = { propagate: true };
+ result.plugins.filler = result.plugins.filler ?? {};
+ result.plugins.filler.propagate = true;
}
return result;
diff --git a/src/components/context-menu/context-menu.component.html b/src/components/context-menu/context-menu.component.html
new file mode 100644
index 0000000..ea849c3
--- /dev/null
+++ b/src/components/context-menu/context-menu.component.html
@@ -0,0 +1 @@
+context-menu works!
diff --git a/src/components/context-menu/context-menu.component.scss b/src/components/context-menu/context-menu.component.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/context-menu/context-menu.component.spec.ts b/src/components/context-menu/context-menu.component.spec.ts
new file mode 100644
index 0000000..1a749a7
--- /dev/null
+++ b/src/components/context-menu/context-menu.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ContextMenuComponent } from './context-menu.component';
+
+describe('ContextMenuComponent', () => {
+ let component: ContextMenuComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ContextMenuComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(ContextMenuComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/components/context-menu/context-menu.component.ts b/src/components/context-menu/context-menu.component.ts
new file mode 100644
index 0000000..3b2c84c
--- /dev/null
+++ b/src/components/context-menu/context-menu.component.ts
@@ -0,0 +1,12 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'fbi-context-menu',
+ standalone: true,
+ imports: [],
+ templateUrl: './context-menu.component.html',
+ styleUrl: './context-menu.component.scss'
+})
+export class ContextMenuComponent {
+
+}
diff --git a/src/components/metadata/metadata.component.html b/src/components/metadata/metadata.component.html
new file mode 100644
index 0000000..1c170da
--- /dev/null
+++ b/src/components/metadata/metadata.component.html
@@ -0,0 +1,6 @@
+
+
diff --git a/src/components/metadata/metadata.component.scss b/src/components/metadata/metadata.component.scss
new file mode 100644
index 0000000..e0c082e
--- /dev/null
+++ b/src/components/metadata/metadata.component.scss
@@ -0,0 +1,17 @@
+:host {
+ display: flex;
+ flex-direction: column;
+}
+
+.header {
+ position: sticky;
+ top: 0px;
+ border: 1px solid black;
+ padding-left: 4px;
+ background-color: lightgray;
+}
+
+.tree {
+ flex: 1;
+ overflow: scroll;
+}
diff --git a/src/components/metadata/metadata.component.spec.ts b/src/components/metadata/metadata.component.spec.ts
new file mode 100644
index 0000000..c7d4025
--- /dev/null
+++ b/src/components/metadata/metadata.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { MetadataComponent } from './metadata.component';
+
+describe('MetadataComponent', () => {
+ let component: MetadataComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [MetadataComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(MetadataComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/components/metadata/metadata.component.ts b/src/components/metadata/metadata.component.ts
new file mode 100644
index 0000000..865b98f
--- /dev/null
+++ b/src/components/metadata/metadata.component.ts
@@ -0,0 +1,75 @@
+import { Component } from '@angular/core';
+import { TreeComponent } from '../tree/tree.component';
+import { TreeNode } from '../../models/tree-node';
+import { MetaService } from '../../services/meta.service';
+import { Action } from '../../models/action';
+import { faAdd } from '@fortawesome/free-solid-svg-icons';
+import { QueryService } from '../../services/query.service';
+import { combineLatest } from 'rxjs';
+import { Query } from '../../models/query';
+
+@Component({
+ selector: 'fbi-metadata',
+ standalone: true,
+ imports: [TreeComponent],
+ templateUrl: './metadata.component.html',
+ styleUrl: './metadata.component.scss',
+})
+export class MetadataComponent {
+ node: TreeNode = new TreeNode({});
+
+ constructor(metaService: MetaService, private queryService: QueryService) {
+ combineLatest({
+ meta: metaService.Data,
+ query: queryService.Query,
+ }).subscribe((d: { meta: Partial[]; query: Query }) => {
+ const inuse = d.query.fields;
+ const expanded = this.getExpanded(this.node);
+
+ const recurse = (node: Partial) => {
+ node.hidden = inuse.includes(node.data);
+ node.expanded = expanded.includes(node.data);
+
+ const children = node.children ?? [];
+ children.forEach((child: Partial) => recurse(child));
+ if (children.length === 0) {
+ const actions = [{ label: 'Add', icon: faAdd, data: ACTIONS.ADD }];
+ node.actions = actions as Action[];
+ }
+ };
+
+ (d.meta ?? []).forEach((node: Partial) => recurse(node));
+
+ this.node = new TreeNode({
+ hidden: true,
+ expanded: true,
+ children: d.meta as TreeNode[],
+ });
+ });
+ }
+
+ onActionClick(event: { action: Action; node: TreeNode }): void {
+ switch (event.action.data) {
+ case ACTIONS.ADD:
+ this.queryService.add(event.node.data);
+ break;
+ }
+ }
+
+ private getExpanded(node: TreeNode): string[] {
+ const result: string[] = [];
+
+ if (node.expanded) {
+ result.push(node.data);
+ }
+ (node.children ?? []).forEach((child: TreeNode) =>
+ result.push(...this.getExpanded(child))
+ );
+
+ return result;
+ }
+}
+
+enum ACTIONS {
+ ADD = 'add',
+}
diff --git a/src/components/query/query.component.html b/src/components/query/query.component.html
new file mode 100644
index 0000000..e19c5f3
--- /dev/null
+++ b/src/components/query/query.component.html
@@ -0,0 +1,6 @@
+
+
diff --git a/src/components/query/query.component.scss b/src/components/query/query.component.scss
new file mode 100644
index 0000000..e0c082e
--- /dev/null
+++ b/src/components/query/query.component.scss
@@ -0,0 +1,17 @@
+:host {
+ display: flex;
+ flex-direction: column;
+}
+
+.header {
+ position: sticky;
+ top: 0px;
+ border: 1px solid black;
+ padding-left: 4px;
+ background-color: lightgray;
+}
+
+.tree {
+ flex: 1;
+ overflow: scroll;
+}
diff --git a/src/components/query/query.component.spec.ts b/src/components/query/query.component.spec.ts
new file mode 100644
index 0000000..60fef27
--- /dev/null
+++ b/src/components/query/query.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { QueryComponent } from './query.component';
+
+describe('QueryComponent', () => {
+ let component: QueryComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [QueryComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(QueryComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/components/query/query.component.ts b/src/components/query/query.component.ts
new file mode 100644
index 0000000..e2d667e
--- /dev/null
+++ b/src/components/query/query.component.ts
@@ -0,0 +1,106 @@
+import { Component } from '@angular/core';
+import { TreeComponent } from '../tree/tree.component';
+import { TreeNode } from '../../models/tree-node';
+import { QueryService } from '../../services/query.service';
+import { Query } from '../../models/query';
+import { Action } from '../../models/action';
+import {
+ faArrowDown,
+ faArrowUp,
+ faRemove,
+} from '@fortawesome/free-solid-svg-icons';
+import { ExecuteService } from '../../services/execute.service';
+import { Header } from '../../models/header';
+
+@Component({
+ selector: 'fbi-query',
+ standalone: true,
+ imports: [TreeComponent],
+ templateUrl: './query.component.html',
+ styleUrl: './query.component.scss',
+})
+export class QueryComponent {
+ node: TreeNode = new TreeNode({});
+
+ constructor(
+ private queryService: QueryService,
+ executeService: ExecuteService
+ ) {
+ queryService.Query.subscribe((query: Query) => {
+ executeService.headers(query).subscribe((headers: Header[]) => {
+ this.node = new TreeNode({
+ hidden: true,
+ expanded: true,
+ children: [
+ {
+ label: 'Fields',
+ leaf: false,
+ expanded: true,
+ children: (query?.fields ?? []).map(
+ (field: string, index: number, array: string[]) => {
+ const actions = [];
+ if (index !== 0) {
+ actions.push({
+ label: 'Move Up',
+ icon: faArrowUp,
+ data: { cmd: ACTIONS.UP, data: index - 1 },
+ });
+ }
+ if (index !== array.length - 1) {
+ actions.push({
+ label: 'Move Down',
+ icon: faArrowDown,
+ data: { cmd: ACTIONS.DOWN, data: index + 1 },
+ });
+ }
+ actions.push({
+ label: 'Remove',
+ icon: faRemove,
+ data: { cmd: ACTIONS.REMOVE },
+ });
+
+ const label =
+ headers.find((header: Header) => header.source === field)
+ ?.label ?? 'unknown';
+
+ return {
+ label: label,
+ data: field,
+ actions: actions,
+ };
+ }
+ ),
+ },
+ { label: 'Filters', expanded: true, leaf: false },
+ ] as TreeNode[],
+ });
+ });
+ });
+ }
+
+ onActionClick(event: { action: Action; node: TreeNode }): void {
+ const data = event.action.data as ActionData;
+ switch (data.cmd) {
+ case ACTIONS.REMOVE:
+ this.queryService.remove(event.node.data);
+ break;
+ case ACTIONS.UP:
+ this.queryService.add(event.node.data, data.data);
+ break;
+ case ACTIONS.DOWN:
+ this.queryService.add(event.node.data, data.data);
+ break;
+ }
+ }
+}
+
+enum ACTIONS {
+ REMOVE = 'del',
+ UP = 'up',
+ DOWN = 'down',
+}
+
+type ActionData = {
+ cmd: ACTIONS;
+ data: any;
+};
diff --git a/src/components/result/result.component.html b/src/components/result/result.component.html
new file mode 100644
index 0000000..86c8134
--- /dev/null
+++ b/src/components/result/result.component.html
@@ -0,0 +1 @@
+
diff --git a/src/components/result/result.component.scss b/src/components/result/result.component.scss
new file mode 100644
index 0000000..5e36a50
--- /dev/null
+++ b/src/components/result/result.component.scss
@@ -0,0 +1,3 @@
+:host {
+ padding: 8px;
+}
diff --git a/src/components/result/result.component.spec.ts b/src/components/result/result.component.spec.ts
new file mode 100644
index 0000000..da5b15b
--- /dev/null
+++ b/src/components/result/result.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ResultComponent } from './result.component';
+
+describe('ResultComponent', () => {
+ let component: ResultComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ResultComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(ResultComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/components/result/result.component.ts b/src/components/result/result.component.ts
new file mode 100644
index 0000000..05b1d0e
--- /dev/null
+++ b/src/components/result/result.component.ts
@@ -0,0 +1,49 @@
+import { CommonModule } from '@angular/common';
+import { Component } from '@angular/core';
+import { TreeComponent } from '../tree/tree.component';
+import { QueryService } from '../../services/query.service';
+import { Query } from '../../models/query';
+import { ExecuteService } from '../../services/execute.service';
+import { forkJoin } from 'rxjs';
+import { Header } from '../../models/header';
+import { TableComponent } from '../table/table.component';
+
+@Component({
+ selector: 'fbi-result',
+ standalone: true,
+ imports: [CommonModule, TreeComponent, TableComponent],
+ templateUrl: './result.component.html',
+ styleUrl: './result.component.scss',
+})
+export class ResultComponent {
+ headers: Header[] = [];
+ rows: Record[] = [];
+
+ constructor(
+ queryService: QueryService,
+ private executeService: ExecuteService
+ ) {
+ let last: string = '';
+ queryService.Query.subscribe((query: Query) => {
+ if (query.isValid()) {
+ const current = query.toString();
+ if (last !== current) {
+ this.load(query);
+ last = current;
+ }
+ }
+ });
+ }
+
+ private load(query: Query): void {
+ forkJoin({
+ headers: this.executeService.headers(query),
+ data: this.executeService.data(query),
+ }).subscribe(
+ (result: { headers: Header[]; data: Record[] }) => {
+ this.headers = result.headers;
+ this.rows = result.data;
+ }
+ );
+ }
+}
diff --git a/src/components/table/table.component.html b/src/components/table/table.component.html
index 691a2bf..fbc6a00 100644
--- a/src/components/table/table.component.html
+++ b/src/components/table/table.component.html
@@ -1,12 +1,13 @@
- |
+ | |
+
{{ h.label }}
|
-
- | {{ index }} |
-
+ |
+ | {{ i }} |
+
{{ row[h.source] }}
|
diff --git a/src/components/table/table.component.ts b/src/components/table/table.component.ts
index 8ce417d..f351a8f 100644
--- a/src/components/table/table.component.ts
+++ b/src/components/table/table.component.ts
@@ -1,7 +1,6 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
-import { Header } from './header';
-import { Observable } from 'rxjs';
+import { Header } from '../../models/header';
@Component({
selector: 'fbi-table',
@@ -11,6 +10,6 @@ import { Observable } from 'rxjs';
styleUrl: './table.component.scss',
})
export class TableComponent {
- @Input() header!: Header[];
- @Input() rows!: Observable[]>;
+ @Input() headers!: Header[];
+ @Input() rows!: Record[];
}
diff --git a/src/components/tree/tree.component.html b/src/components/tree/tree.component.html
new file mode 100644
index 0000000..0efd0ba
--- /dev/null
+++ b/src/components/tree/tree.component.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+ {{ node.label }}
+
+
+
+
+
+
+
+
diff --git a/src/components/tree/tree.component.scss b/src/components/tree/tree.component.scss
new file mode 100644
index 0000000..0a917f8
--- /dev/null
+++ b/src/components/tree/tree.component.scss
@@ -0,0 +1,56 @@
+.branch {
+ margin-left: 20px;
+ overflow: hidden;
+}
+
+.treenode {
+ color: #333;
+ margin: 0;
+ white-space: nowrap;
+ display: flex;
+ margin-right: 2px;
+}
+
+.treenode:hover {
+ background-color: #edf8fa;
+}
+
+.icon {
+ display: inline-block;
+ width: 16px;
+ text-align: center;
+}
+
+.fill {
+ flex: 1;
+}
+
+.clickable {
+ cursor: pointer;
+}
+
+.disabled {
+ color: dimgray;
+ opacity: 0.5;
+}
+
+.right {
+ float: right;
+}
+
+.ellipsis {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.treenode:hover > .action {
+ visibility: visible;
+ width: auto;
+}
+
+.action {
+ visibility: hidden;
+ width: 0px;
+ padding-left: 2px;
+}
diff --git a/src/components/tree/tree.component.spec.ts b/src/components/tree/tree.component.spec.ts
new file mode 100644
index 0000000..898a58e
--- /dev/null
+++ b/src/components/tree/tree.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { TreeComponent } from './tree.component';
+
+describe('TreeComponent', () => {
+ let component: TreeComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TreeComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TreeComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/components/tree/tree.component.ts b/src/components/tree/tree.component.ts
new file mode 100644
index 0000000..425afb3
--- /dev/null
+++ b/src/components/tree/tree.component.ts
@@ -0,0 +1,40 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { TreeNode } from '../../models/tree-node';
+import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
+import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons';
+import { Action } from '../../models/action';
+
+@Component({
+ selector: 'fbi-tree',
+ standalone: true,
+ imports: [CommonModule, FontAwesomeModule],
+ templateUrl: './tree.component.html',
+ styleUrl: './tree.component.scss',
+})
+export class TreeComponent {
+ @Input() node!: TreeNode;
+ @Output() actionClick = new EventEmitter<{
+ action: Action;
+ node: TreeNode;
+ }>();
+
+ faCaretDown = faCaretDown;
+ faCaretRight = faCaretRight;
+
+ toggle(event: MouseEvent): void {
+ if (!this.node.leaf) {
+ event.stopPropagation();
+ this.node.expanded = !this.node.expanded;
+ }
+ }
+
+ passActionClick(event: { action: Action; node: TreeNode }): void {
+ this.actionClick.emit(event);
+ }
+
+ onActionClick(event: MouseEvent, action: Action): void {
+ event.stopPropagation();
+ this.actionClick.emit({ action: action, node: this.node });
+ }
+}
diff --git a/src/models/action.ts b/src/models/action.ts
new file mode 100644
index 0000000..f3f5d89
--- /dev/null
+++ b/src/models/action.ts
@@ -0,0 +1,13 @@
+import { faDumbbell, IconDefinition } from '@fortawesome/free-solid-svg-icons';
+
+export class Action {
+ label: string;
+ icon: IconDefinition;
+ data: any;
+
+ constructor(data: Partial) {
+ this.label = data?.label ?? '';
+ this.icon = data?.icon ?? faDumbbell;
+ this.data = data?.data;
+ }
+}
diff --git a/src/components/table/header.ts b/src/models/header.ts
similarity index 100%
rename from src/components/table/header.ts
rename to src/models/header.ts
diff --git a/src/models/page.ts b/src/models/page.ts
new file mode 100644
index 0000000..ee9ec06
--- /dev/null
+++ b/src/models/page.ts
@@ -0,0 +1,13 @@
+export class Page {
+ start: number;
+ limit: number;
+
+ constructor(data: Partial) {
+ this.start = data?.start ?? 1;
+ this.limit = data?.limit ?? 20;
+ }
+
+ toString(): string {
+ return `(${this.start}:${this.limit})`;
+ }
+}
diff --git a/src/models/query.ts b/src/models/query.ts
new file mode 100644
index 0000000..309bf94
--- /dev/null
+++ b/src/models/query.ts
@@ -0,0 +1,23 @@
+import { Page } from './page';
+
+export class Query {
+ fields: string[];
+ filter: string[];
+ page: Page;
+
+ constructor(data: Partial) {
+ this.fields = data?.fields ?? [];
+ this.filter = data?.filter ?? [];
+ this.page = new Page(data?.page ?? {});
+ }
+
+ isValid(): boolean {
+ return true;
+ }
+
+ toString(): string {
+ const fields = (this.fields ?? []).join(', ');
+ const filters = '';
+ return `${fields}${filters}`;
+ }
+}
diff --git a/src/models/tree-node.ts b/src/models/tree-node.ts
new file mode 100644
index 0000000..c427f61
--- /dev/null
+++ b/src/models/tree-node.ts
@@ -0,0 +1,38 @@
+import { Action } from './action';
+
+export class TreeNode {
+ label: string;
+ data: any;
+ children: TreeNode[];
+ leaf: boolean;
+ expanded: boolean;
+ hidden: boolean;
+ disabled: boolean;
+ selected: boolean;
+ actions: Action[];
+
+ index: number;
+ isFirst: boolean;
+ isLast: boolean;
+
+ constructor(data: Partial) {
+ this.label = data?.label ?? '';
+ this.data = data?.data ?? null;
+ this.children = (data?.children ?? []).map(
+ (i: Partial, index: number) =>
+ new TreeNode({ ...i, ...{ index: index } })
+ );
+ this.leaf = data?.leaf ?? this.children.length === 0;
+ this.expanded = data?.expanded ?? false;
+ this.hidden = data?.hidden ?? false;
+ this.disabled = data?.disabled ?? false;
+ this.selected = data?.selected ?? false;
+ this.actions = (data?.actions ?? []).map(
+ (i: Partial) => new Action(i)
+ );
+
+ this.index = data?.index ?? 0;
+ this.isFirst = data?.isFirst ?? false;
+ this.isLast = data?.isLast ?? false;
+ }
+}
diff --git a/src/services/data.service.ts b/src/services/data.service.ts
index ed39c6b..f915bfe 100644
--- a/src/services/data.service.ts
+++ b/src/services/data.service.ts
@@ -8,33 +8,38 @@ import { Milestone } from '../enums/milestone';
@Injectable({ providedIn: 'root' })
export class DataService {
private _data: Record[] = [];
- private data = new ReplaySubject[]>();
- readonly Data = this.data.asObservable();
+ private data = new ReplaySubject[]>(1);
+ readonly Data$ = this.data.asObservable();
+ readonly Data = () => this._data;
private _counties = new Map();
- private counties = new ReplaySubject();
- readonly Counties = this.counties.asObservable();
+ private counties = new ReplaySubject(1);
+ readonly Counties$ = this.counties.asObservable();
+ readonly Counties = () => this._counties;
private _schools = new Map();
- private schools = new ReplaySubject();
- readonly Schools = this.schools.asObservable();
+ private schools = new ReplaySubject(1);
+ readonly Schools$ = this.schools.asObservable();
+ readonly Schools = () => this._schools;
private _years = Array(2023 - 2015 + 1)
.fill(0)
- .map((_, index) => 2015 + index)
- .filter((y) => y !== 2020);
- private years = new ReplaySubject();
- readonly Years = this.years.asObservable();
+ .map((_, index) => 2015 + index);
+ private years = new ReplaySubject(1);
+ readonly Years$ = this.years.asObservable();
+ readonly Years = () => this._years;
private _grades = Array(8 - 3 + 1)
.fill(0)
.map((_, index) => 3 + index);
- private grades = new ReplaySubject();
- readonly Grades = this.grades.asObservable();
+ private grades = new ReplaySubject(1);
+ readonly Grades$ = this.grades.asObservable();
+ readonly Grades = () => this._grades;
private _cohorts = new Map();
- private cohorts = new ReplaySubject();
- readonly Cohorts = this.cohorts.asObservable();
+ private cohorts = new ReplaySubject(1);
+ readonly Cohorts$ = this.cohorts.asObservable();
+ readonly Cohorts = () => this._cohorts;
constructor() {
this.load();
@@ -44,7 +49,7 @@ export class DataService {
let count = this._years.length * this._grades.length;
this._years.forEach((year: number) => {
this._grades.forEach((grade: number) => {
- Papa.parse(`assets/data/${year}-${grade}.csv`, {
+ Papa.parse(`assets/data/${year === 2020 ? 2019 : year}-${grade}.csv`, {
download: true,
header: false,
complete: (results: ParseResult>) => {
@@ -68,36 +73,37 @@ export class DataService {
if (isNaN(code)) return;
const ms = {} as Record;
- // const ms = new Milestone({});
ms[Milestone.Year] = year;
ms[Milestone.Grade] = grade;
ms[Milestone.Cohort] = cohort;
ms[Milestone.County] = fs(Csv.County_Code, record);
ms[Milestone.School] = fs(Csv.School_Code, record);
- ms[Milestone.ELACount] = fn(Csv.ELA_Count, record);
- ms[Milestone.ELAMean] = fn(Csv.ELA_Mean, record);
- ms[Milestone.ELA0] = fn(Csv.ELA_L0, record);
- ms[Milestone.ELA1] = fn(Csv.ELA_L1, record);
- ms[Milestone.ELA2] = fn(Csv.ELA_L2, record);
- ms[Milestone.ELA3] = fn(Csv.ELA_L3, record);
- ms[Milestone.MathCount] = fn(Csv.Math_Count, record);
- ms[Milestone.MathMean] = fn(Csv.Math_Mean, record);
- ms[Milestone.Math0] = fn(Csv.Math_L0, record);
- ms[Milestone.Math1] = fn(Csv.Math_L1, record);
- ms[Milestone.Math2] = fn(Csv.Math_L2, record);
- ms[Milestone.Math3] = fn(Csv.Math_L3, record);
- ms[Milestone.SciCount] = fn(Csv.Science_Count, record);
- ms[Milestone.SciMean] = fn(Csv.Science_Mean, record);
- ms[Milestone.Sci0] = fn(Csv.Science_L0, record);
- ms[Milestone.Sci1] = fn(Csv.Science_L1, record);
- ms[Milestone.Sci2] = fn(Csv.Science_L2, record);
- ms[Milestone.Sci3] = fn(Csv.Science_L3, record);
- ms[Milestone.SocCount] = fn(Csv.Social_Count, record);
- ms[Milestone.SocMean] = fn(Csv.Social_Mean, record);
- ms[Milestone.Soc0] = fn(Csv.Social_L0, record);
- ms[Milestone.Soc1] = fn(Csv.Social_L1, record);
- ms[Milestone.Soc2] = fn(Csv.Social_L2, record);
- ms[Milestone.Soc3] = fn(Csv.Social_L3, record);
+ if (year !== 2020) {
+ ms[Milestone.ELACount] = fn(Csv.ELA_Count, record);
+ ms[Milestone.ELAMean] = fn(Csv.ELA_Mean, record);
+ ms[Milestone.ELA0] = fn(Csv.ELA_L0, record);
+ ms[Milestone.ELA1] = fn(Csv.ELA_L1, record);
+ ms[Milestone.ELA2] = fn(Csv.ELA_L2, record);
+ ms[Milestone.ELA3] = fn(Csv.ELA_L3, record);
+ ms[Milestone.MathCount] = fn(Csv.Math_Count, record);
+ ms[Milestone.MathMean] = fn(Csv.Math_Mean, record);
+ ms[Milestone.Math0] = fn(Csv.Math_L0, record);
+ ms[Milestone.Math1] = fn(Csv.Math_L1, record);
+ ms[Milestone.Math2] = fn(Csv.Math_L2, record);
+ ms[Milestone.Math3] = fn(Csv.Math_L3, record);
+ ms[Milestone.SciCount] = fn(Csv.Science_Count, record);
+ ms[Milestone.SciMean] = fn(Csv.Science_Mean, record);
+ ms[Milestone.Sci0] = fn(Csv.Science_L0, record);
+ ms[Milestone.Sci1] = fn(Csv.Science_L1, record);
+ ms[Milestone.Sci2] = fn(Csv.Science_L2, record);
+ ms[Milestone.Sci3] = fn(Csv.Science_L3, record);
+ ms[Milestone.SocCount] = fn(Csv.Social_Count, record);
+ ms[Milestone.SocMean] = fn(Csv.Social_Mean, record);
+ ms[Milestone.Soc0] = fn(Csv.Social_L0, record);
+ ms[Milestone.Soc1] = fn(Csv.Social_L1, record);
+ ms[Milestone.Soc2] = fn(Csv.Social_L2, record);
+ ms[Milestone.Soc3] = fn(Csv.Social_L3, record);
+ }
data.push(ms);
const county = fs(Csv.County_Code, record);
@@ -108,21 +114,24 @@ export class DataService {
);
});
- [
- { sort: Milestone.ELAMean, rank: Milestone.ELARank },
- { sort: Milestone.MathMean, rank: Milestone.MathRank },
- { sort: Milestone.SciMean, rank: Milestone.SciRank },
- { sort: Milestone.SocMean, rank: Milestone.SocRank },
- ].forEach((m) => {
- data
- .sort(
- (a: Record, b: Record) =>
- +a[m.sort] - b[m.sort]
- )
- .forEach(
- (a: Record, index: number) => (a[m.rank] = index)
- );
- });
+ if (year !== 2020) {
+ [
+ { sort: Milestone.ELAMean, rank: Milestone.ELARank },
+ { sort: Milestone.MathMean, rank: Milestone.MathRank },
+ { sort: Milestone.SciMean, rank: Milestone.SciRank },
+ { sort: Milestone.SocMean, rank: Milestone.SocRank },
+ ].forEach((m) => {
+ data
+ .sort(
+ (a: Record, b: Record) =>
+ +a[m.sort] - b[m.sort]
+ )
+ .forEach(
+ (a: Record, index: number) =>
+ (a[m.rank] = index)
+ );
+ });
+ }
this._data.push(...data);
diff --git a/src/services/execute.service.ts b/src/services/execute.service.ts
new file mode 100644
index 0000000..d0c8df4
--- /dev/null
+++ b/src/services/execute.service.ts
@@ -0,0 +1,81 @@
+import { Injectable } from '@angular/core';
+import { DataService } from './data.service';
+import { Query } from '../models/query';
+import { Observable, of, map, take } from 'rxjs';
+import { Header } from '../models/header';
+import { MetaService } from './meta.service';
+
+@Injectable({ providedIn: 'root' })
+export class ExecuteService {
+ constructor(
+ private dataService: DataService,
+ private metaService: MetaService
+ ) {}
+
+ headers(query: Query): Observable {
+ if (!query?.isValid()) return of([]);
+
+ const fields = query.fields;
+ return this.metaService.Flat.pipe(
+ map((items: Header[]) =>
+ items
+ .filter((item: Header) => fields.includes(item.source))
+ .sort(
+ (a: Header, b: Header) =>
+ fields.indexOf(a.source) - fields.indexOf(b.source)
+ )
+ ),
+ take(1)
+ );
+ }
+
+ data(query: Query): Observable[]> {
+ if (!query?.isValid()) return of([]);
+
+ const fields = query.fields;
+
+ return this.dataService.Data$.pipe(
+ // apply filter
+ map((data: Record[]) => {
+ const filter = query.filter;
+ return data.filter((i: Record) => {
+ // apply filter to i to determine if it should be returned
+ return true;
+ });
+ }),
+ // apply fields
+ map((data: Record[]) =>
+ data.map((i: Record) => {
+ const r = {} as Record;
+ fields.forEach((field: string) => (r[field] = i[field]));
+ return r;
+ })
+ ),
+ // make the data unique and apply paging
+ map((data: Record[]) => {
+ let i = 0;
+ const result: Record[] = [];
+ const page = query.page;
+
+ data.some((d: Record) => {
+ const idx = result.findIndex((r: Record) =>
+ fields
+ .map((field: string) => d[field] === r[field])
+ .reduce((acc, cur) => acc && cur, true)
+ );
+
+ // if this element has not been seen before, add it to the results we care about.
+ if (idx < 0) {
+ result.push(d);
+ ++i;
+ }
+ return i >= page.start + page.limit;
+ });
+
+ return result.slice(page.start, page.start + page.limit);
+ }),
+ // force it to close
+ take(1)
+ );
+ }
+}
diff --git a/src/services/meta.service.ts b/src/services/meta.service.ts
new file mode 100644
index 0000000..c59894c
--- /dev/null
+++ b/src/services/meta.service.ts
@@ -0,0 +1,110 @@
+import { Injectable } from '@angular/core';
+import { Milestone } from '../enums/milestone';
+import { map, ReplaySubject } from 'rxjs';
+import { TreeNode } from '../models/tree-node';
+import { Header } from '../models/header';
+
+@Injectable({ providedIn: 'root' })
+export class MetaService {
+ private data = [
+ {
+ label: 'School',
+ data: 'node.school',
+ children: [
+ { data: Milestone.County, label: 'County' },
+ { data: Milestone.School, label: 'Name' },
+ ],
+ },
+ {
+ label: 'Class',
+ data: 'node.class',
+ children: [
+ { data: Milestone.Cohort, label: 'Cohort' },
+ { data: Milestone.Year, label: 'Year' },
+ { data: Milestone.Grade, label: 'Grade' },
+ ],
+ },
+ {
+ label: 'English Language Arts',
+ data: 'node.ela',
+ children: [
+ { data: Milestone.ELACount, label: 'Count' },
+ { data: Milestone.ELAMean, label: 'Mean' },
+ { data: Milestone.ELARank, label: 'Rank' },
+ { data: Milestone.ELA0, label: 'Beginning Learner %' },
+ { data: Milestone.ELA1, label: 'Developing Learner %' },
+ { data: Milestone.ELA2, label: 'Proficient Learner %' },
+ { data: Milestone.ELA3, label: 'Advanced Learner %' },
+ ],
+ },
+ {
+ label: 'Mathematics',
+ data: 'node.math',
+ children: [
+ { data: Milestone.MathCount, label: 'Count' },
+ { data: Milestone.MathMean, label: 'Mean' },
+ { data: Milestone.MathRank, label: 'Rank' },
+ { data: Milestone.Math0, label: 'Beginning Learner %' },
+ { data: Milestone.Math1, label: 'Developing Learner %' },
+ { data: Milestone.Math2, label: 'Proficient Learner %' },
+ { data: Milestone.Math3, label: 'Advanced Learner %' },
+ ],
+ },
+ {
+ label: 'Science',
+ data: 'node.sci',
+ children: [
+ { data: Milestone.SciCount, label: 'Count' },
+ { data: Milestone.SciMean, label: 'Mean' },
+ { data: Milestone.SciRank, label: 'Rank' },
+ { data: Milestone.Sci0, label: 'Beginning Learner %' },
+ { data: Milestone.Sci1, label: 'Developing Learner %' },
+ { data: Milestone.Sci2, label: 'Proficient Learner %' },
+ { data: Milestone.Sci3, label: 'Advanced Learner %' },
+ ],
+ },
+ {
+ label: 'Social Studies',
+ data: 'node.soc',
+ children: [
+ { data: Milestone.SocCount, label: 'Count' },
+ { data: Milestone.SocMean, label: 'Mean' },
+ { data: Milestone.SocRank, label: 'Rank' },
+ { data: Milestone.Soc0, label: 'Beginning Learner %' },
+ { data: Milestone.Soc1, label: 'Developing Learner %' },
+ { data: Milestone.Soc2, label: 'Proficient Learner %' },
+ { data: Milestone.Soc3, label: 'Advanced Learner %' },
+ ],
+ },
+ ];
+ private subject = new ReplaySubject[]>(1);
+ readonly Data = this.subject.asObservable();
+ readonly Flat = this.subject.asObservable().pipe(
+ map((data: Partial[]) => {
+ const recurse = (path: string, item: Partial): Header[] => {
+ const result = [];
+ const children = item?.children ?? [];
+ if (children.length > 0) {
+ children.forEach((child: Partial) => {
+ result.push(...recurse(`${path}${item.label}`, child));
+ });
+ } else {
+ result.push(
+ new Header({ source: item.data, label: `${path} - ${item.label}` })
+ );
+ }
+ return result;
+ };
+
+ const result: Header[] = [];
+ data.forEach((item: Partial) =>
+ result.push(...recurse('', item))
+ );
+ return result;
+ })
+ );
+
+ constructor() {
+ this.subject.next(this.data as Partial[]);
+ }
+}
diff --git a/src/services/query.service.ts b/src/services/query.service.ts
new file mode 100644
index 0000000..f138376
--- /dev/null
+++ b/src/services/query.service.ts
@@ -0,0 +1,24 @@
+import { BehaviorSubject } from 'rxjs';
+import { Query } from '../models/query';
+import { Injectable } from '@angular/core';
+
+@Injectable({ providedIn: 'root' })
+export class QueryService {
+ private querySubject = new BehaviorSubject(new Query({}));
+ readonly Query = this.querySubject.asObservable();
+
+ constructor() {}
+
+ add(field: string, index: number = -1): void {
+ const q = new Query(this.querySubject.value);
+ q.fields = q.fields.filter((f: string) => f !== field);
+ q.fields.splice(index < 0 ? q.fields.length : index, 0, field);
+ this.querySubject.next(q);
+ }
+
+ remove(field: string): void {
+ const q = new Query(this.querySubject.value);
+ q.fields = q.fields.filter((f: string) => f !== field);
+ this.querySubject.next(q);
+ }
+}
diff --git a/src/styles.scss b/src/styles.scss
index 90d4ee0..578eda4 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -1 +1,5 @@
/* You can add global styles to this file, and also import other style files */
+body {
+ // margin: 0;
+ font-family: Arial, Helvetica, sans-serif;
+}