Compare commits
2 Commits
e88499cbaf
...
1e3dc2938b
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e3dc2938b | |||
| 224e6388c1 |
@@ -25,7 +25,8 @@
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": ["src/favicon.ico", "src/assets"],
|
||||
"styles": ["src/styles.scss"],
|
||||
"scripts": []
|
||||
"scripts": [],
|
||||
"allowedCommonJsDependencies": ["papaparse"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
|
||||
49
package-lock.json
generated
49
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -16,9 +16,17 @@
|
||||
[school]="school"
|
||||
></fbi-scores> -->
|
||||
|
||||
<fbi-cohort-section
|
||||
<!-- <fbi-cohort-section
|
||||
*ngFor="let cohort of cohorts"
|
||||
[cohort]="cohort"
|
||||
[county]="county"
|
||||
[school]="school"
|
||||
></fbi-cohort-section>
|
||||
></fbi-cohort-section> -->
|
||||
|
||||
<div class="menu">
|
||||
<fbi-query class="query"></fbi-query>
|
||||
<fbi-metadata class="metadata"></fbi-metadata>
|
||||
</div>
|
||||
<div class="content">
|
||||
<fbi-result class="result"></fbi-result>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<string, any>[]> = this.data.Data;
|
||||
rows: Observable<Record<string, any>[]> = 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[]) => {
|
||||
|
||||
@@ -1 +1 @@
|
||||
<fbi-chart [config]="chart" [data]="data"></fbi-chart>
|
||||
<fbi-chart [config]="chart" [data]="data" class="chart"></fbi-chart>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.chart {
|
||||
height: 400px;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
@@ -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<string, any>[]) => {
|
||||
this.dataService.Cohorts$.subscribe(
|
||||
(cohorts: KeyValue[]) =>
|
||||
(this.chart.title = cohorts
|
||||
.map((cohort: KeyValue) => cohort.value)
|
||||
.join(', '))
|
||||
);
|
||||
|
||||
this.dataService.Data$.subscribe((data: Record<string, any>[]) => {
|
||||
this.data = data
|
||||
.filter(
|
||||
(d: Record<string, any>) =>
|
||||
|
||||
@@ -80,7 +80,7 @@ export class ScoresComponent implements OnInit {
|
||||
const school = this.school;
|
||||
const grade = this.grade;
|
||||
|
||||
this.dataService.Data.subscribe((data: Record<string, any>[]) => {
|
||||
this.dataService.Data$.subscribe((data: Record<string, any>[]) => {
|
||||
this.data = data.filter(
|
||||
(d: Record<string, any>) =>
|
||||
d[Milestone.County] === county &&
|
||||
|
||||
@@ -3,5 +3,6 @@ import { ChartAxis } from './chart-type';
|
||||
|
||||
export interface ChartConfig {
|
||||
type: ChartType;
|
||||
title?: string;
|
||||
axis: ChartAxis[];
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface IDataset {
|
||||
xAxisKey?: string;
|
||||
yAxisKey?: string;
|
||||
};
|
||||
spanGaps?: boolean;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
<canvas #chart></canvas>
|
||||
<canvas #chart class="fit"></canvas>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.fit {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export class DatasetService {
|
||||
if (axis.fill) {
|
||||
entry.fill = axis.fill;
|
||||
}
|
||||
entry.spanGaps = true;
|
||||
result.push(entry);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
1
src/components/context-menu/context-menu.component.html
Normal file
1
src/components/context-menu/context-menu.component.html
Normal file
@@ -0,0 +1 @@
|
||||
<p>context-menu works!</p>
|
||||
23
src/components/context-menu/context-menu.component.spec.ts
Normal file
23
src/components/context-menu/context-menu.component.spec.ts
Normal file
@@ -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<ContextMenuComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ContextMenuComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ContextMenuComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
12
src/components/context-menu/context-menu.component.ts
Normal file
12
src/components/context-menu/context-menu.component.ts
Normal file
@@ -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 {
|
||||
|
||||
}
|
||||
6
src/components/metadata/metadata.component.html
Normal file
6
src/components/metadata/metadata.component.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<div class="header">Fields!</div>
|
||||
<fbi-tree
|
||||
class="tree"
|
||||
[node]="node"
|
||||
(actionClick)="onActionClick($event)"
|
||||
></fbi-tree>
|
||||
17
src/components/metadata/metadata.component.scss
Normal file
17
src/components/metadata/metadata.component.scss
Normal file
@@ -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;
|
||||
}
|
||||
23
src/components/metadata/metadata.component.spec.ts
Normal file
23
src/components/metadata/metadata.component.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MetadataComponent } from './metadata.component';
|
||||
|
||||
describe('MetadataComponent', () => {
|
||||
let component: MetadataComponent;
|
||||
let fixture: ComponentFixture<MetadataComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MetadataComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MetadataComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
75
src/components/metadata/metadata.component.ts
Normal file
75
src/components/metadata/metadata.component.ts
Normal file
@@ -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<TreeNode>[]; query: Query }) => {
|
||||
const inuse = d.query.fields;
|
||||
const expanded = this.getExpanded(this.node);
|
||||
|
||||
const recurse = (node: Partial<TreeNode>) => {
|
||||
node.hidden = inuse.includes(node.data);
|
||||
node.expanded = expanded.includes(node.data);
|
||||
|
||||
const children = node.children ?? [];
|
||||
children.forEach((child: Partial<TreeNode>) => 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<TreeNode>) => 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',
|
||||
}
|
||||
6
src/components/query/query.component.html
Normal file
6
src/components/query/query.component.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<div class="header">Query!</div>
|
||||
<fbi-tree
|
||||
class="tree"
|
||||
[node]="node"
|
||||
(actionClick)="onActionClick($event)"
|
||||
></fbi-tree>
|
||||
17
src/components/query/query.component.scss
Normal file
17
src/components/query/query.component.scss
Normal file
@@ -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;
|
||||
}
|
||||
23
src/components/query/query.component.spec.ts
Normal file
23
src/components/query/query.component.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { QueryComponent } from './query.component';
|
||||
|
||||
describe('QueryComponent', () => {
|
||||
let component: QueryComponent;
|
||||
let fixture: ComponentFixture<QueryComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [QueryComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(QueryComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
106
src/components/query/query.component.ts
Normal file
106
src/components/query/query.component.ts
Normal file
@@ -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;
|
||||
};
|
||||
1
src/components/result/result.component.html
Normal file
1
src/components/result/result.component.html
Normal file
@@ -0,0 +1 @@
|
||||
<fbi-table [headers]="headers" [rows]="rows"></fbi-table>
|
||||
3
src/components/result/result.component.scss
Normal file
3
src/components/result/result.component.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
padding: 8px;
|
||||
}
|
||||
23
src/components/result/result.component.spec.ts
Normal file
23
src/components/result/result.component.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ResultComponent } from './result.component';
|
||||
|
||||
describe('ResultComponent', () => {
|
||||
let component: ResultComponent;
|
||||
let fixture: ComponentFixture<ResultComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ResultComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ResultComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
49
src/components/result/result.component.ts
Normal file
49
src/components/result/result.component.ts
Normal file
@@ -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<string, any>[] = [];
|
||||
|
||||
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<string, any>[] }) => {
|
||||
this.headers = result.headers;
|
||||
this.rows = result.data;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
<table>
|
||||
<tr>
|
||||
<th *ngFor="let h of header">
|
||||
<th></th>
|
||||
<th *ngFor="let h of headers">
|
||||
{{ h.label }}
|
||||
</th>
|
||||
</tr>
|
||||
<tr *ngFor="let row of rows | async; let index">
|
||||
<td>{{ index }}</td>
|
||||
<td *ngFor="let h of header">
|
||||
<tr *ngFor="let row of rows; index as i">
|
||||
<td>{{ i }}</td>
|
||||
<td *ngFor="let h of headers">
|
||||
{{ row[h.source] }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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<Record<string, any>[]>;
|
||||
@Input() headers!: Header[];
|
||||
@Input() rows!: Record<string, any>[];
|
||||
}
|
||||
|
||||
32
src/components/tree/tree.component.html
Normal file
32
src/components/tree/tree.component.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<div
|
||||
*ngIf="!node?.hidden"
|
||||
class="treenode"
|
||||
[class.disabled]="node.disabled"
|
||||
[class.clickable]="!node.leaf"
|
||||
(click)="toggle($event)"
|
||||
>
|
||||
<span *ngIf="!node.leaf" class="icon">
|
||||
<fa-icon *ngIf="node.expanded" [icon]="faCaretDown"></fa-icon>
|
||||
<fa-icon *ngIf="!node.expanded" [icon]="faCaretRight"></fa-icon>
|
||||
</span>
|
||||
<span [title]="node.label" class="fill ellipsis">{{ node.label }}</span>
|
||||
<ng-container *ngIf="node.leaf && !node.disabled" class="actions">
|
||||
<fa-icon
|
||||
*ngFor="let action of node.actions"
|
||||
class="action clickable"
|
||||
[icon]="action.icon"
|
||||
(click)="onActionClick($event, action)"
|
||||
></fa-icon>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="node && node.children && node.expanded"
|
||||
[class.branch]="node.label !== ''"
|
||||
>
|
||||
<fbi-tree
|
||||
*ngFor="let child of node.children"
|
||||
[node]="child"
|
||||
(actionClick)="passActionClick($event)"
|
||||
></fbi-tree>
|
||||
</div>
|
||||
56
src/components/tree/tree.component.scss
Normal file
56
src/components/tree/tree.component.scss
Normal file
@@ -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;
|
||||
}
|
||||
22
src/components/tree/tree.component.spec.ts
Normal file
22
src/components/tree/tree.component.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { TreeComponent } from './tree.component';
|
||||
|
||||
describe('TreeComponent', () => {
|
||||
let component: TreeComponent;
|
||||
let fixture: ComponentFixture<TreeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TreeComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TreeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
40
src/components/tree/tree.component.ts
Normal file
40
src/components/tree/tree.component.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
13
src/models/action.ts
Normal file
13
src/models/action.ts
Normal file
@@ -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<Action>) {
|
||||
this.label = data?.label ?? '';
|
||||
this.icon = data?.icon ?? faDumbbell;
|
||||
this.data = data?.data;
|
||||
}
|
||||
}
|
||||
13
src/models/page.ts
Normal file
13
src/models/page.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export class Page {
|
||||
start: number;
|
||||
limit: number;
|
||||
|
||||
constructor(data: Partial<Page>) {
|
||||
this.start = data?.start ?? 1;
|
||||
this.limit = data?.limit ?? 20;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `(${this.start}:${this.limit})`;
|
||||
}
|
||||
}
|
||||
23
src/models/query.ts
Normal file
23
src/models/query.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Page } from './page';
|
||||
|
||||
export class Query {
|
||||
fields: string[];
|
||||
filter: string[];
|
||||
page: Page;
|
||||
|
||||
constructor(data: Partial<Query>) {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
38
src/models/tree-node.ts
Normal file
38
src/models/tree-node.ts
Normal file
@@ -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<TreeNode>) {
|
||||
this.label = data?.label ?? '';
|
||||
this.data = data?.data ?? null;
|
||||
this.children = (data?.children ?? []).map(
|
||||
(i: Partial<TreeNode>, 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<Action>) => new Action(i)
|
||||
);
|
||||
|
||||
this.index = data?.index ?? 0;
|
||||
this.isFirst = data?.isFirst ?? false;
|
||||
this.isLast = data?.isLast ?? false;
|
||||
}
|
||||
}
|
||||
@@ -8,33 +8,38 @@ import { Milestone } from '../enums/milestone';
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DataService {
|
||||
private _data: Record<string, any>[] = [];
|
||||
private data = new ReplaySubject<Record<string, any>[]>();
|
||||
readonly Data = this.data.asObservable();
|
||||
private data = new ReplaySubject<Record<string, any>[]>(1);
|
||||
readonly Data$ = this.data.asObservable();
|
||||
readonly Data = () => this._data;
|
||||
|
||||
private _counties = new Map<string, string>();
|
||||
private counties = new ReplaySubject<KeyValue[]>();
|
||||
readonly Counties = this.counties.asObservable();
|
||||
private counties = new ReplaySubject<KeyValue[]>(1);
|
||||
readonly Counties$ = this.counties.asObservable();
|
||||
readonly Counties = () => this._counties;
|
||||
|
||||
private _schools = new Map<string, string>();
|
||||
private schools = new ReplaySubject<KeyValue[]>();
|
||||
readonly Schools = this.schools.asObservable();
|
||||
private schools = new ReplaySubject<KeyValue[]>(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<KeyValue[]>();
|
||||
readonly Years = this.years.asObservable();
|
||||
.map((_, index) => 2015 + index);
|
||||
private years = new ReplaySubject<KeyValue[]>(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<KeyValue[]>();
|
||||
readonly Grades = this.grades.asObservable();
|
||||
private grades = new ReplaySubject<KeyValue[]>(1);
|
||||
readonly Grades$ = this.grades.asObservable();
|
||||
readonly Grades = () => this._grades;
|
||||
|
||||
private _cohorts = new Map<string, string>();
|
||||
private cohorts = new ReplaySubject<KeyValue[]>();
|
||||
readonly Cohorts = this.cohorts.asObservable();
|
||||
private cohorts = new ReplaySubject<KeyValue[]>(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<Record<string, any>>) => {
|
||||
@@ -68,12 +73,12 @@ export class DataService {
|
||||
if (isNaN(code)) return;
|
||||
|
||||
const ms = {} as Record<string, any>;
|
||||
// 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);
|
||||
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);
|
||||
@@ -98,6 +103,7 @@ export class DataService {
|
||||
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,6 +114,7 @@ export class DataService {
|
||||
);
|
||||
});
|
||||
|
||||
if (year !== 2020) {
|
||||
[
|
||||
{ sort: Milestone.ELAMean, rank: Milestone.ELARank },
|
||||
{ sort: Milestone.MathMean, rank: Milestone.MathRank },
|
||||
@@ -120,9 +127,11 @@ export class DataService {
|
||||
+a[m.sort] - b[m.sort]
|
||||
)
|
||||
.forEach(
|
||||
(a: Record<string, any>, index: number) => (a[m.rank] = index)
|
||||
(a: Record<string, any>, index: number) =>
|
||||
(a[m.rank] = index)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this._data.push(...data);
|
||||
|
||||
|
||||
81
src/services/execute.service.ts
Normal file
81
src/services/execute.service.ts
Normal file
@@ -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<Header[]> {
|
||||
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<Record<string, any>[]> {
|
||||
if (!query?.isValid()) return of([]);
|
||||
|
||||
const fields = query.fields;
|
||||
|
||||
return this.dataService.Data$.pipe(
|
||||
// apply filter
|
||||
map((data: Record<string, any>[]) => {
|
||||
const filter = query.filter;
|
||||
return data.filter((i: Record<string, any>) => {
|
||||
// apply filter to i to determine if it should be returned
|
||||
return true;
|
||||
});
|
||||
}),
|
||||
// apply fields
|
||||
map((data: Record<string, any>[]) =>
|
||||
data.map((i: Record<string, any>) => {
|
||||
const r = {} as Record<string, any>;
|
||||
fields.forEach((field: string) => (r[field] = i[field]));
|
||||
return r;
|
||||
})
|
||||
),
|
||||
// make the data unique and apply paging
|
||||
map((data: Record<string, any>[]) => {
|
||||
let i = 0;
|
||||
const result: Record<string, any>[] = [];
|
||||
const page = query.page;
|
||||
|
||||
data.some((d: Record<string, any>) => {
|
||||
const idx = result.findIndex((r: Record<string, any>) =>
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
110
src/services/meta.service.ts
Normal file
110
src/services/meta.service.ts
Normal file
@@ -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<Partial<TreeNode>[]>(1);
|
||||
readonly Data = this.subject.asObservable();
|
||||
readonly Flat = this.subject.asObservable().pipe(
|
||||
map((data: Partial<TreeNode>[]) => {
|
||||
const recurse = (path: string, item: Partial<TreeNode>): Header[] => {
|
||||
const result = [];
|
||||
const children = item?.children ?? [];
|
||||
if (children.length > 0) {
|
||||
children.forEach((child: Partial<TreeNode>) => {
|
||||
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<TreeNode>) =>
|
||||
result.push(...recurse('', item))
|
||||
);
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.subject.next(this.data as Partial<TreeNode>[]);
|
||||
}
|
||||
}
|
||||
24
src/services/query.service.ts
Normal file
24
src/services/query.service.ts
Normal file
@@ -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<Query>(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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user