diff --git a/packages/drafts-realm/CampaignMembersChart/89e02ea7-7d04-45c9-90c9-7062e38c87a2.json b/packages/drafts-realm/CampaignMembersChart/89e02ea7-7d04-45c9-90c9-7062e38c87a2.json new file mode 100644 index 0000000000..40698c193b --- /dev/null +++ b/packages/drafts-realm/CampaignMembersChart/89e02ea7-7d04-45c9-90c9-7062e38c87a2.json @@ -0,0 +1,48 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Email July 2024", + "chartType": "Vertical Bar", + "contactMembers": [ + { + "responseStatus": "Sent" + }, + { + "responseStatus": "Responded" + } + ], + "leadMembers": [ + { + "responseStatus": "Responded" + } + ], + "title": null, + "description": null, + "thumbnailURL": null + }, + "relationships": { + "contactMembers.0.contactForm": { + "links": { + "self": "../ContactForm/24fad614-e49b-44cf-9bf4-f9ead589ffa5" + } + }, + "contactMembers.1.contactForm": { + "links": { + "self": "../ContactForm/24fad614-e49b-44cf-9bf4-f9ead589ffa5" + } + }, + "leadMembers.0.leadForm": { + "links": { + "self": "../LeadForm/ad388806-9aa7-485b-90ed-66331a6da6bb" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../campaign-members-chart", + "name": "CampaignMembersChart" + } + } + } +} \ No newline at end of file diff --git a/packages/drafts-realm/campaign-members-chart.gts b/packages/drafts-realm/campaign-members-chart.gts new file mode 100644 index 0000000000..a2bcc4f57e --- /dev/null +++ b/packages/drafts-realm/campaign-members-chart.gts @@ -0,0 +1,627 @@ +import { ContactForm } from './contact-form'; +import { LeadForm } from './lead-form'; + +import { + Component, + CardDef, + FieldDef, + field, + contains, + StringField, + linksTo, + containsMany, +} from 'https://cardstack.com/base/card-api'; +import { + FieldContainer, + BoxelSelect, + BoxelInput, +} from '@cardstack/boxel-ui/components'; +import GlimmerComponent from '@glimmer/component'; +import { action } from '@ember/object'; +import { htmlSafe } from '@ember/template'; + +// @ts-ignore +import * as d3 from 'https://cdn.jsdelivr.net/npm/d3@7.9.0/+esm'; + +class ContactMembersFieldEdit extends Component { + get selectedResponseStatus() { + return { + name: this.args.model.responseStatus, + }; + } + + get responseStatusFieldStyle() { + let css: string[] = []; + css.push('margin-top:var(--boxel-sp-sm);'); + return htmlSafe(css.join(' ')); + } + + @action updateResponseStatus(type: { name: string }) { + this.args.model.responseStatus = type.name; + } + + private responseStatuses = [{ name: 'Sent' }, { name: 'Responded' }]; + + +} + +class ContactMembersField extends FieldDef { + static displayName = 'ContactMember'; + @field contactForm = linksTo(ContactForm); + @field responseStatus = contains(StringField); + + static edit = ContactMembersFieldEdit; +} + +class LeadMembersFieldEdit extends Component { + get selectedResponseStatus() { + return { + name: this.args.model.responseStatus, + }; + } + + get responseStatusFieldStyle() { + let css: string[] = []; + css.push('margin-top:var(--boxel-sp-sm);'); + return htmlSafe(css.join(' ')); + } + + @action updateResponseStatus(type: { name: string }) { + this.args.model.responseStatus = type.name; + } + + private responseStatuses = [{ name: 'Sent' }, { name: 'Responded' }]; + + +} + +class LeadMembersField extends FieldDef { + static displayName = 'LeadMember'; + @field leadForm = linksTo(LeadForm); + @field responseStatus = contains(StringField); + + static edit = LeadMembersFieldEdit; +} + +interface ChartSignature { + Args: { + numberSent: number; + numberResponsed: number; + }; + Element: HTMLElement; +} + +class DonutChart extends GlimmerComponent { + get displayDonut() { + if (typeof document === 'undefined') { + return; + } + + const data = [ + { name: 'Sent', value: this.args.numberSent }, + { name: 'Responded', value: this.args.numberResponsed }, + ]; + + const width = 200; + const height = 200; + const radius = Math.min(width, height) / 2; + + const color = d3.scaleOrdinal(d3.schemeCategory10); + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', width.toString()); + svg.setAttribute('height', height.toString()); + + const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + g.setAttribute('transform', `translate(${width / 2}, ${height / 2})`); + + svg.appendChild(g); + + // Add middle text + const middleText = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'text', + ); + middleText.setAttribute('text-anchor', 'middle'); + middleText.setAttribute('dy', '.35em'); + middleText.setAttribute('font-size', '20px'); + middleText.textContent = ( + this.args.numberSent + this.args.numberResponsed + ).toString(); + g.appendChild(middleText); + + const tooltip = d3 + .select('.donut-chart') + .append('div') + .attr('class', 'tooltip') + .style('opacity', 0) + .style('position', 'absolute') + .style('background-color', '#ffffff') + .style('border', 'solid') + .style('border-width', '1px') + .style('border-radius', '8px') + .style('padding', '0.5rem') + .style('color', 'black') + .style('width', '160px') + .style('pointer-events', 'none'); + + const arc = d3 + .arc() + .innerRadius(radius - 50) + .outerRadius(radius); + + const pie = d3.pie().value((d: { value: any }) => d.value); + + const arcs = d3 + .select(g) + .selectAll('.arc') + .data(pie(data)) + .enter() + .append('g') + .attr('class', 'arc'); + + arcs + .append('path') + .attr('d', arc) + .style('fill', (d: any) => color(d.data.name)) + .on('mouseover', function (event, d) { + d3.select(this).style('opacity', 0.7); + tooltip + .style('opacity', 1) + .html( + `
Status
${d.data.name}
Number of members: ${d.data.value}
`, + ) + .style('left', event.layerX + 10 + 'px') + .style('top', event.layerY + 10 + 'px'); + }) + .on('mouseout', function (event, d) { + d3.select(this).style('opacity', 1); + tooltip.style('opacity', 0); + }); + + arcs + .append('text') + .attr('transform', (d: any) => 'translate(' + arc.centroid(d) + ')') + .attr('dy', '.35em') + .style('text-anchor', 'middle') + .text((d: any) => d.data.value); + + return svg; + } + + +} + +class HorizontalBarChart extends GlimmerComponent { + get displayHorizontalBar() { + if (typeof document === 'undefined') { + return; + } + + const data = [ + { name: 'Sent', value: this.args.numberSent }, + { name: 'Responded', value: this.args.numberResponsed }, + ]; + + const width = 400; + const height = 250; + const marginTop = 40; + const marginRight = 20; + const marginBottom = 40; + const marginLeft = 100; + + const color = d3.scaleOrdinal(d3.schemeCategory10); + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', width.toString()); + svg.setAttribute('height', height.toString()); + + // create horizontal bar chart with category/value axis label and value axis text + const x = d3 + .scaleLinear() + .domain([0, d3.max(data, (d) => d.value)]) + .range([0, width - marginLeft - marginRight]); + + const y = d3 + .scaleBand() + .domain(data.map((d) => d.name)) + .range([0, height - marginTop - marginBottom]) + .padding(0.1); + + const g = d3 + .select(svg) + .append('g') + .attr('transform', `translate(${marginLeft}, ${marginTop})`); + + g.append('g') + .selectAll('rect') + .data(data) + .enter() + .append('rect') + .attr('y', (d) => y(d.name)) + .attr('width', (d) => x(d.value)) + .attr('height', y.bandwidth()) + .attr('fill', (d) => color(d.name)); + + g.append('g') + .selectAll('text') + .data(data) + .enter() + .append('text') + .attr('x', (d) => x(d.value) - 20) + .attr('y', (d) => y(d.name) + y.bandwidth() / 2) + .attr('dy', '0.35em') + .text((d) => d.value); + + g.append('g') + .attr('transform', `translate(0, ${height - marginTop - marginBottom})`) + .call(d3.axisBottom(x)); + + g.append('g').call(d3.axisLeft(y)); + + return svg; + } + + +} + +class VerticalBarChart extends GlimmerComponent { + get displayVerticalBar() { + if (typeof document === 'undefined') { + return; + } + + const data = [ + { name: 'Sent', value: this.args.numberSent }, + { name: 'Responded', value: this.args.numberResponsed }, + ]; + + const width = 400; + const height = 250; + const marginTop = 40; + const marginRight = 20; + const marginBottom = 40; + const marginLeft = 100; + + const color = d3.scaleOrdinal(d3.schemeCategory10); + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', width.toString()); + svg.setAttribute('height', height.toString()); + + // create vertical bar chart with category/value axis label and value axis text + const x = d3 + .scaleBand() + .domain(data.map((d) => d.name)) + .range([0, width - marginLeft - marginRight]) + .padding(0.1); + + const y = d3 + .scaleLinear() + .domain([0, d3.max(data, (d) => d.value)]) + .range([height - marginTop - marginBottom, 0]); + + const g = d3 + .select(svg) + .append('g') + .attr('transform', `translate(${marginLeft}, ${marginTop})`); + + g.append('g') + .selectAll('rect') + .data(data) + .enter() + .append('rect') + .attr('x', (d) => x(d.name)) + .attr('y', (d) => y(d.value)) + .attr('width', x.bandwidth()) + .attr('height', (d) => height - marginTop - marginBottom - y(d.value)) + .attr('fill', (d) => color(d.name)); + + g.append('g') + .selectAll('text') + .data(data) + .enter() + .append('text') + .attr('x', (d) => x(d.name) + x.bandwidth() / 2) + .attr('y', (d) => y(d.value) + 15) + .attr('dy', '0.35em') + .text((d) => d.value); + + g.append('g').call(d3.axisLeft(y)); + + g.append('g') + .attr('transform', `translate(0, ${height - marginTop - marginBottom})`) + .call(d3.axisBottom(x)); + + return svg; + } + + +} + +class Isolated extends Component { + get numberSent() { + let { model } = this.args; + const contactMembers = + model.contactMembers?.filter( + (contactMember) => contactMember.responseStatus === 'Sent', + ) || []; + const leadMembers = + model.leadMembers?.filter( + (leadMember) => leadMember.responseStatus === 'Sent', + ) || []; + return contactMembers.length + leadMembers.length; + } + + get numberResponsed() { + let { model } = this.args; + const contactMembers = + model.contactMembers?.filter( + (contactMember) => contactMember.responseStatus === 'Responded', + ) || []; + const leadMembers = + model.leadMembers?.filter( + (leadMember) => leadMember.responseStatus === 'Responded', + ) || []; + return contactMembers.length + leadMembers.length; + } + + get chartType() { + return this.args.model.chartType; + } + + get isChartTypeDonut() { + return this.chartType === 'Donut'; + } + + get isChartTypeHorizontalBar() { + return this.chartType === 'Horizontal Bar'; + } + + get isChartTypeVerticalBar() { + return this.chartType === 'Vertical Bar'; + } + + +} + +class Embedded extends Component { + +} + +class Edit extends Component { + get selectedChartType() { + return { + name: this.args.model.chartType, + }; + } + + @action updateName(inputText: string) { + this.args.model.name = inputText; + } + + @action updateChartType(type: { name: string }) { + this.args.model.chartType = type.name; + } + + private campaignChartTypes = [ + { name: 'Donut' }, + { name: 'Vertical Bar' }, + { name: 'Horizontal Bar' }, + ]; + + +} + +export class CampaignMembersChart extends CardDef { + static displayName = 'CampaignMembersChart'; + + @field name = contains(StringField, { + description: 'The campaign name', + }); + @field chartType = contains(StringField, { + description: + 'Chart type that will be displayed for showing sent and responded members', + }); + @field contactMembers = containsMany(ContactMembersField, { + description: 'Contact members of the campaign, each with response status', + }); + @field leadMembers = containsMany(LeadMembersField, { + description: 'Lead members of the campaign, each with response status', + }); + + static isolated = Isolated; + static embedded = Embedded; + static atom = Embedded; + static edit = Edit; +}