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 @@ +
Fields!
+ 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 @@ +
Query!
+ 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 @@ - + - - - + + 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; +}
+ {{ h.label }}
{{ index }} +
{{ i }} {{ row[h.source] }}