Spaces:
Sleeping
Sleeping
| import { AGE_BANDS, GENDERS, LOCATION_OPTIONS, PERSONA_OPTIONS, REGIONS } from "./data"; | |
| import type { | |
| AgeBandId, | |
| CategoricalAnswer, | |
| GenderId, | |
| LikertAnswer, | |
| LocationId, | |
| OpenAnswer, | |
| PersonaAttributeId, | |
| PersonaDimensionId, | |
| QuestionStat, | |
| RegionId, | |
| RegionStat, | |
| SiliconConfig, | |
| SiliconResult, | |
| SurveyQuestion, | |
| SyntheticRespondent, | |
| WeightedPick, | |
| } from "./types"; | |
| const REGION_TENDENCY: Record<RegionId, number> = { | |
| seoul: 0.03, | |
| busan: -0.02, | |
| daegu: -0.08, | |
| incheon: 0.01, | |
| gwangju: 0.1, | |
| daejeon: 0.02, | |
| ulsan: -0.03, | |
| sejong: 0.04, | |
| gyeonggi: 0.02, | |
| gangwon: -0.02, | |
| chungbuk: -0.01, | |
| chungnam: -0.02, | |
| jeonbuk: 0.06, | |
| jeonnam: 0.08, | |
| gyeongbuk: -0.07, | |
| gyeongnam: -0.04, | |
| jeju: 0.05, | |
| }; | |
| const AGE_TENDENCY: Record<AgeBandId, number> = { | |
| "20s": 0.03, | |
| "30s": 0.01, | |
| "40s": 0, | |
| "50s": -0.01, | |
| "60plus": -0.02, | |
| }; | |
| const THEMES = ["물가", "주거", "일자리", "돌봄", "교통", "의료", "지역경제", "교육", "기후", "정치 신뢰"]; | |
| const PERSONA_DIMENSIONS: PersonaDimensionId[] = ["occupation", "education", "housing", "marital", "family"]; | |
| export function defaultPicks<T extends string>(items: Array<{ id: T; defaultWeight: number }>): WeightedPick<T>[] { | |
| return items.map((item) => ({ id: item.id, enabled: true, weight: item.defaultWeight })); | |
| } | |
| export function simulateSiliconSampling(config: SiliconConfig): SiliconResult { | |
| const rand = mulberry32(config.seed); | |
| const respondents: SyntheticRespondent[] = []; | |
| const likertAnswers: LikertAnswer[] = []; | |
| const categoricalAnswers: CategoricalAnswer[] = []; | |
| const openAnswers: OpenAnswer[] = []; | |
| const likertQuestions = config.questions.filter((question) => question.kind === "likert"); | |
| const categoricalQuestions = config.questions.filter((question) => question.kind === "categorical"); | |
| const openQuestions = config.questions.filter((question) => question.kind === "open"); | |
| const genderPool = buildAllocationPool(config.genders, config.sampleSize, "female", rand); | |
| const agePool = buildAllocationPool(config.ages, config.sampleSize, "40s", rand); | |
| const locationPool = buildAllocationPool(config.locations, config.sampleSize, "seoul", rand); | |
| const personaPools = buildPersonaPools(config.personaAttributes, config.sampleSize, rand); | |
| for (let index = 0; index < config.sampleSize; index += 1) { | |
| const gender = genderPool[index] || "female"; | |
| const age = agePool[index] || "40s"; | |
| const location = locationPool[index] || "seoul"; | |
| const locationOption = locationOptionOf(location, config.locationOptions); | |
| const persona = personaAttributesFromPools(personaPools, index); | |
| const respondent = buildRespondent(index, gender, age, locationOption.id, locationOption.parentRegion, locationOption.label, persona, rand); | |
| respondents.push(respondent); | |
| for (const question of likertQuestions) { | |
| likertAnswers.push({ | |
| respondentId: respondent.id, | |
| questionId: question.id, | |
| value: answerLikert(question, respondent, rand), | |
| rationale: answerLikertRationale(question, respondent), | |
| }); | |
| } | |
| for (const question of categoricalQuestions) { | |
| categoricalAnswers.push({ | |
| respondentId: respondent.id, | |
| questionId: question.id, | |
| ...answerCategorical(question, respondent, rand), | |
| }); | |
| } | |
| for (const question of openQuestions) { | |
| openAnswers.push({ | |
| respondentId: respondent.id, | |
| questionId: question.id, | |
| ...answerOpen(question, respondent, rand), | |
| }); | |
| } | |
| } | |
| const primaryQuestionId = likertQuestions[0]?.id ?? null; | |
| return { | |
| config, | |
| respondents, | |
| likertAnswers, | |
| categoricalAnswers, | |
| openAnswers, | |
| regionStats: buildRegionStats(config.locations, config.locationOptions, respondents, likertAnswers, openAnswers, likertQuestions[0]), | |
| questionStats: buildQuestionStats(config.questions, likertAnswers, categoricalAnswers), | |
| primaryQuestionId, | |
| }; | |
| } | |
| function buildRespondent( | |
| index: number, | |
| gender: GenderId, | |
| age: AgeBandId, | |
| location: LocationId, | |
| region: RegionId, | |
| locationLabel: string, | |
| persona: ReturnType<typeof pickPersonaAttributes>, | |
| rand: () => number, | |
| ): SyntheticRespondent { | |
| const regionBias = REGION_TENDENCY[region] || 0; | |
| const ageBias = AGE_TENDENCY[age] || 0; | |
| const genderBias = gender === "female" ? 0.015 : gender === "male" ? -0.01 : 0; | |
| const personaBias = personaBiases(persona.attributes); | |
| const economicAnxiety = clamp01(0.5 + (age === "30s" || age === "40s" ? 0.08 : 0) + (region === "seoul" || region === "gyeonggi" ? 0.04 : 0) + personaBias.anxiety + noise(rand, 0.18)); | |
| const trust = clamp01(0.48 + regionBias + ageBias + genderBias + personaBias.trust - economicAnxiety * 0.08 + noise(rand, 0.16)); | |
| const participation = clamp01(0.5 + (age === "60plus" ? 0.12 : 0) + (age === "20s" ? -0.06 : 0) + Math.abs(regionBias) * 0.4 + personaBias.participation + noise(rand, 0.14)); | |
| return { | |
| id: `R${String(index + 1).padStart(4, "0")}`, | |
| gender, | |
| age, | |
| region, | |
| location, | |
| locationLabel, | |
| personaAttributes: persona.attributes, | |
| personaLabels: persona.labels, | |
| segment: segmentLabel(trust, economicAnxiety, participation), | |
| trust, | |
| economicAnxiety, | |
| participation, | |
| }; | |
| } | |
| function answerLikert(question: SurveyQuestion, respondent: SyntheticRespondent, rand: () => number) { | |
| const scale = question.scale || 5; | |
| let score = scale / 2 + 0.5; | |
| const trustTerm = (respondent.trust - 0.5) * scale * 0.8; | |
| const anxietyTerm = (respondent.economicAnxiety - 0.5) * scale * 0.58; | |
| const participationTerm = (respondent.participation - 0.5) * scale * 0.32; | |
| const regionTerm = (REGION_TENDENCY[respondent.region] || 0) * scale; | |
| if (question.id.includes("approval") || question.id.includes("trust")) score += trustTerm + regionTerm; | |
| else if (question.id.includes("economic") || question.id.includes("household")) score += -anxietyTerm + trustTerm * 0.28; | |
| else if (question.id.includes("climate")) score += participationTerm + (respondent.age === "20s" ? 0.35 : 0) - (respondent.age === "60plus" ? 0.18 : 0); | |
| else if (question.id.includes("birth")) score += -anxietyTerm * 0.35 + (respondent.age === "30s" ? -0.18 : 0.08); | |
| else if (question.id.includes("news")) score += trustTerm * 0.35 - (respondent.age === "20s" ? 0.1 : 0) + (respondent.age === "60plus" ? 0.15 : 0); | |
| else score += trustTerm * 0.35 - anxietyTerm * 0.18; | |
| score += noise(rand, scale * 0.36); | |
| return Math.max(1, Math.min(scale, Math.round(score))); | |
| } | |
| function answerLikertRationale(question: SurveyQuestion, respondent: SyntheticRespondent) { | |
| const age = labelOf(AGE_BANDS, respondent.age); | |
| const gender = labelOf(GENDERS, respondent.gender); | |
| const region = labelOf(REGIONS, respondent.region); | |
| const persona = respondent.personaLabels.occupation || respondent.segment; | |
| if (question.id.includes("economic") || question.id.includes("household")) { | |
| return `${region} 거주 ${age} ${gender} ${persona} 응답자로서 생활비와 소득 안정성을 함께 고려했습니다.`; | |
| } | |
| if (question.id.includes("trust") || question.id.includes("approval")) { | |
| return `${region} 거주 ${age} ${gender} ${persona} 응답자로서 제도 신뢰와 최근 정책 체감도를 기준으로 판단했습니다.`; | |
| } | |
| if (question.id.includes("climate")) { | |
| return `${region} 거주 ${age} ${gender} ${persona} 응답자로서 환경 필요성과 비용 부담을 함께 보았습니다.`; | |
| } | |
| return `${region} 거주 ${age} ${gender} ${persona} 응답자로서 현재 생활 여건과 관심사를 반영했습니다.`; | |
| } | |
| function answerOpen(question: SurveyQuestion, respondent: SyntheticRespondent, rand: () => number): { text: string; theme: string; rationale: string } { | |
| const region = labelOf(REGIONS, respondent.region); | |
| const age = labelOf(AGE_BANDS, respondent.age); | |
| const occupation = respondent.personaLabels.occupation ? ` ${respondent.personaLabels.occupation}` : ""; | |
| let theme = THEMES[Math.floor(rand() * THEMES.length)]; | |
| if (respondent.economicAnxiety > 0.68) theme = rand() > 0.5 ? "물가" : "주거"; | |
| if (respondent.age === "20s" || respondent.age === "30s") theme = rand() > 0.45 ? "일자리" : "주거"; | |
| if (respondent.age === "60plus") theme = rand() > 0.5 ? "의료" : "돌봄"; | |
| if (question.id.includes("local")) theme = rand() > 0.5 ? "교통" : "지역경제"; | |
| if (question.id.includes("policy")) theme = rand() > 0.5 ? "주거" : "정치 신뢰"; | |
| const tone = respondent.trust > 0.58 ? "지금보다 체감 가능한 방식으로 확대되면 좋겠습니다" : "구호보다 실제 집행과 설명이 먼저 필요합니다"; | |
| const text = `${region} 거주 ${age}${occupation} 응답자로서 ${theme} 문제가 가장 크게 느껴집니다. ${tone}.`; | |
| const rationale = `${region}, ${age}, ${occupation.trim() || respondent.segment} 특성에서 가장 직접적으로 체감되는 이슈를 우선했습니다.`; | |
| return { theme, text, rationale }; | |
| } | |
| function answerCategorical(question: SurveyQuestion, respondent: SyntheticRespondent, rand: () => number): { optionId: string; label: string; rationale: string } { | |
| const options = question.options?.length ? question.options : [{ id: "opt_1", label: "기타" }]; | |
| const ageTilt = respondent.age === "20s" || respondent.age === "30s" ? 0 : respondent.age === "60plus" ? 2 : 1; | |
| const anxietyTilt = respondent.economicAnxiety > 0.6 ? 0 : 1; | |
| const index = Math.min(options.length - 1, Math.max(0, Math.floor((rand() * options.length + ageTilt + anxietyTilt) / 3))); | |
| const option = options[index] || options[0]; | |
| const rationale = `${labelOf(REGIONS, respondent.region)} 거주 ${labelOf(AGE_BANDS, respondent.age)} 응답자로서 현재 생활 여건과 persona 특성에 가장 가까운 선택지를 골랐습니다.`; | |
| return { optionId: option.id, label: option.label, rationale }; | |
| } | |
| function buildRegionStats( | |
| locations: WeightedPick<LocationId>[], | |
| locationOptions: SiliconConfig["locationOptions"], | |
| respondents: SyntheticRespondent[], | |
| likertAnswers: LikertAnswer[], | |
| openAnswers: OpenAnswer[], | |
| primaryQuestion?: SurveyQuestion, | |
| ): RegionStat[] { | |
| const primaryQuestionId = primaryQuestion?.id ?? null; | |
| const scale = primaryQuestion?.scale || 5; | |
| const positiveCut = Math.max(3, Math.ceil(scale * 0.7)); | |
| const enabledLocations = locations.filter((location) => location.enabled && location.weight > 0); | |
| return enabledLocations.map((locationPick) => { | |
| const option = locationOptionOf(locationPick.id, locationOptions); | |
| const people = respondents.filter((respondent) => respondent.location === option.id); | |
| const ids = new Set(people.map((respondent) => respondent.id)); | |
| const answers = primaryQuestionId ? likertAnswers.filter((answer) => answer.questionId === primaryQuestionId && ids.has(answer.respondentId)) : []; | |
| return { | |
| region: option.id, | |
| parentRegion: option.parentRegion, | |
| label: option.label, | |
| respondents: people.length, | |
| mean: mean(answers.map((answer) => answer.value)), | |
| scale, | |
| positiveShare: answers.length ? answers.filter((answer) => answer.value >= positiveCut).length / answers.length : 0, | |
| openCount: openAnswers.filter((answer) => ids.has(answer.respondentId)).length, | |
| }; | |
| }); | |
| } | |
| function buildQuestionStats(questions: SurveyQuestion[], likertAnswers: LikertAnswer[], categoricalAnswers: CategoricalAnswer[]): QuestionStat[] { | |
| return questions.map((question) => { | |
| if (question.kind === "open") return { questionId: question.id, title: question.title, kind: "open" }; | |
| if (question.kind === "categorical") { | |
| const answers = categoricalAnswers.filter((answer) => answer.questionId === question.id); | |
| const options = question.options || []; | |
| const distribution = options.map((option) => { | |
| const count = answers.filter((answer) => answer.optionId === option.id).length; | |
| return { optionId: option.id, label: option.label, count, share: answers.length ? count / answers.length : 0 }; | |
| }); | |
| return { | |
| questionId: question.id, | |
| title: question.title, | |
| kind: "categorical", | |
| distribution, | |
| }; | |
| } | |
| const scale = question.scale || 5; | |
| const answers = likertAnswers.filter((answer) => answer.questionId === question.id); | |
| const distribution = Array.from({ length: scale }, (_, index) => { | |
| const value = index + 1; | |
| const count = answers.filter((answer) => answer.value === value).length; | |
| return { value, count, share: answers.length ? count / answers.length : 0 }; | |
| }); | |
| const positiveCut = Math.max(3, Math.ceil(scale * 0.7)); | |
| return { | |
| questionId: question.id, | |
| title: question.title, | |
| kind: "likert", | |
| scale, | |
| mean: mean(answers.map((answer) => answer.value)), | |
| positiveShare: answers.length ? answers.filter((answer) => answer.value >= positiveCut).length / answers.length : 0, | |
| distribution, | |
| }; | |
| }); | |
| } | |
| export function groupBreakdown(result: SiliconResult, dimension: "gender" | "age") { | |
| const primary = result.primaryQuestionId; | |
| if (!primary) return []; | |
| const answerByRespondent = new Map(result.likertAnswers.filter((answer) => answer.questionId === primary).map((answer) => [answer.respondentId, answer.value])); | |
| const options = dimension === "gender" ? GENDERS : AGE_BANDS; | |
| return options.map((option) => { | |
| const people = result.respondents.filter((respondent) => respondent[dimension] === option.id); | |
| const values = people.map((respondent) => answerByRespondent.get(respondent.id)).filter((value): value is number => typeof value === "number"); | |
| return { | |
| id: option.id, | |
| label: option.label, | |
| respondents: people.length, | |
| mean: mean(values), | |
| }; | |
| }); | |
| } | |
| export function resultBreakdown(result: SiliconResult, questionId: string, dimension: "gender" | "age" | "region" | PersonaDimensionId) { | |
| const question = result.config.questions.find((item) => item.id === questionId); | |
| if (!question || (question.kind !== "likert" && question.kind !== "categorical")) return []; | |
| const valuesByRespondent = new Map<string, number | string>(); | |
| if (question.kind === "likert") { | |
| for (const answer of result.likertAnswers.filter((item) => item.questionId === questionId)) { | |
| valuesByRespondent.set(answer.respondentId, answer.value); | |
| } | |
| } else { | |
| for (const answer of result.categoricalAnswers.filter((item) => item.questionId === questionId)) { | |
| valuesByRespondent.set(answer.respondentId, answer.optionId); | |
| } | |
| } | |
| const scale = question.scale || 5; | |
| const positiveCut = Math.max(3, Math.ceil(scale * 0.7)); | |
| const options = dimension === "gender" | |
| ? GENDERS.map((option) => ({ id: option.id, label: option.label })) | |
| : dimension === "age" | |
| ? AGE_BANDS.map((option) => ({ id: option.id, label: option.label })) | |
| : dimension === "region" | |
| ? result.regionStats.map((option) => ({ id: option.region, label: option.label })) | |
| : personaBreakdownOptions(result, dimension); | |
| return options.map((option) => { | |
| const id = option.id; | |
| const label = option.label; | |
| const people = result.respondents.filter((respondent) => { | |
| if (dimension === "gender") return respondent.gender === id; | |
| if (dimension === "age") return respondent.age === id; | |
| if (dimension === "region") return respondent.location === id; | |
| return respondent.personaAttributes[dimension] === id; | |
| }); | |
| const values = people.map((respondent) => valuesByRespondent.get(respondent.id)).filter((value): value is number | string => value !== undefined); | |
| const numericValues = values.filter((value): value is number => typeof value === "number"); | |
| const distribution = question.kind === "likert" | |
| ? Array.from({ length: scale }, (_, index) => { | |
| const value = index + 1; | |
| const count = values.filter((item) => item === value).length; | |
| return { id: String(value), label: `${value}점`, count, share: values.length ? count / values.length : 0 }; | |
| }) | |
| : (question.options || []).map((questionOption) => { | |
| const count = values.filter((item) => item === questionOption.id).length; | |
| return { id: questionOption.id, label: questionOption.label, count, share: values.length ? count / values.length : 0 }; | |
| }); | |
| return { | |
| id, | |
| label, | |
| respondents: people.length, | |
| mean: mean(numericValues), | |
| positiveShare: numericValues.length ? numericValues.filter((value) => value >= positiveCut).length / numericValues.length : 0, | |
| distribution, | |
| }; | |
| }).filter((item) => item.respondents > 0); | |
| } | |
| function personaBreakdownOptions(result: SiliconResult, dimension: PersonaDimensionId) { | |
| const rows = new Map<string, { id: string; label: string }>(); | |
| for (const respondent of result.respondents) { | |
| const id = respondent.personaAttributes[dimension]; | |
| if (!id) continue; | |
| rows.set(id, { id, label: respondent.personaLabels[dimension] || id }); | |
| } | |
| return [...rows.values()]; | |
| } | |
| export function selectedWeightTotal<T extends string>(picks: WeightedPick<T>[]) { | |
| return picks.filter((pick) => pick.enabled).reduce((total, pick) => total + Math.max(0, pick.weight), 0); | |
| } | |
| function pickWeighted<T extends string>(items: WeightedPick<T>[], rand: () => number, fallback: T): T { | |
| const enabled = items.filter((item) => item.enabled && item.weight > 0); | |
| const total = selectedWeightTotal(enabled); | |
| if (!enabled.length || total <= 0) return fallback; | |
| let cursor = rand() * total; | |
| for (const item of enabled) { | |
| cursor -= item.weight; | |
| if (cursor <= 0) return item.id; | |
| } | |
| return enabled[enabled.length - 1].id; | |
| } | |
| function buildAllocationPool<T extends string>(items: WeightedPick<T>[], targetSize: number, fallback: T, rand: () => number): T[] { | |
| const enabled = items.filter((item) => item.enabled && item.weight > 0); | |
| if (!enabled.length) return Array.from({ length: targetSize }, () => fallback); | |
| const allocations = allocatePickCounts(enabled, targetSize); | |
| const pool: T[] = []; | |
| for (const item of enabled) { | |
| const count = allocations.get(item.id) || 0; | |
| for (let index = 0; index < count; index += 1) pool.push(item.id); | |
| } | |
| while (pool.length < targetSize) pool.push(fallback); | |
| shuffle(pool, rand); | |
| return pool.slice(0, targetSize); | |
| } | |
| function allocatePickCounts<T extends string>(items: WeightedPick<T>[], targetSize: number) { | |
| const total = items.reduce((sum, item) => sum + Math.max(0, item.weight), 0); | |
| if (total <= 0) return new Map<T, number>(); | |
| const rows = items.map((item) => { | |
| const raw = Math.max(0, item.weight) / total * targetSize; | |
| return { id: item.id, floor: Math.floor(raw), remainder: raw - Math.floor(raw) }; | |
| }); | |
| let used = rows.reduce((sum, row) => sum + row.floor, 0); | |
| for (const row of rows.sort((a, b) => b.remainder - a.remainder)) { | |
| if (used >= targetSize) break; | |
| row.floor += 1; | |
| used += 1; | |
| } | |
| return new Map(rows.map((row) => [row.id, row.floor])); | |
| } | |
| function shuffle<T>(items: T[], rand: () => number) { | |
| for (let index = items.length - 1; index > 0; index -= 1) { | |
| const swapIndex = Math.floor(rand() * (index + 1)); | |
| [items[index], items[swapIndex]] = [items[swapIndex], items[index]]; | |
| } | |
| } | |
| function locationOptionOf(id: LocationId, options: SiliconConfig["locationOptions"] = LOCATION_OPTIONS) { | |
| return options.find((location) => location.id === id) || LOCATION_OPTIONS.find((location) => location.id === "seoul")!; | |
| } | |
| function pickPersonaAttributes(items: WeightedPick<PersonaAttributeId>[], rand: () => number) { | |
| const attributes: Partial<Record<PersonaDimensionId, PersonaAttributeId>> = {}; | |
| const labels: Partial<Record<PersonaDimensionId, string>> = {}; | |
| for (const dimension of PERSONA_DIMENSIONS) { | |
| const options = PERSONA_OPTIONS.filter((option) => option.dimension === dimension); | |
| const picks = items.filter((item) => options.some((option) => option.id === item.id)); | |
| const picked = pickWeighted(picks, rand, "" as PersonaAttributeId); | |
| if (!picked) continue; | |
| const option = PERSONA_OPTIONS.find((candidate) => candidate.id === picked); | |
| if (!option) continue; | |
| attributes[dimension] = option.id; | |
| labels[dimension] = option.label; | |
| } | |
| return { attributes, labels }; | |
| } | |
| function buildPersonaPools(items: WeightedPick<PersonaAttributeId>[], targetSize: number, rand: () => number) { | |
| const pools = new Map<PersonaDimensionId, PersonaAttributeId[]>(); | |
| for (const dimension of PERSONA_DIMENSIONS) { | |
| const options = PERSONA_OPTIONS.filter((option) => option.dimension === dimension); | |
| const picks = items.filter((item) => options.some((option) => option.id === item.id)); | |
| pools.set(dimension, buildAllocationPool(picks, targetSize, "" as PersonaAttributeId, rand)); | |
| } | |
| return pools; | |
| } | |
| function personaAttributesFromPools(pools: Map<PersonaDimensionId, PersonaAttributeId[]>, index: number) { | |
| const attributes: Partial<Record<PersonaDimensionId, PersonaAttributeId>> = {}; | |
| const labels: Partial<Record<PersonaDimensionId, string>> = {}; | |
| for (const dimension of PERSONA_DIMENSIONS) { | |
| const picked = pools.get(dimension)?.[index]; | |
| if (!picked) continue; | |
| const option = PERSONA_OPTIONS.find((candidate) => candidate.id === picked); | |
| if (!option) continue; | |
| attributes[dimension] = option.id; | |
| labels[dimension] = option.label; | |
| } | |
| return { attributes, labels }; | |
| } | |
| function personaBiases(attributes: Partial<Record<PersonaDimensionId, PersonaAttributeId>>) { | |
| let trust = 0; | |
| let anxiety = 0; | |
| let participation = 0; | |
| if (attributes.occupation === "occ_self_employed") anxiety += 0.05; | |
| if (attributes.occupation === "occ_student") participation += 0.03; | |
| if (attributes.occupation === "occ_retired") participation += 0.04; | |
| if (attributes.occupation === "occ_professional") trust += 0.02; | |
| if (attributes.education === "edu_graduate" || attributes.education === "edu_bachelor") participation += 0.02; | |
| if (attributes.housing === "housing_officetel") anxiety += 0.03; | |
| if (attributes.family === "family_children") anxiety += 0.02; | |
| if (attributes.family === "family_single") participation -= 0.01; | |
| return { trust, anxiety, participation }; | |
| } | |
| function labelOf<T extends string>(items: Array<{ id: T; label: string }>, id: T) { | |
| return items.find((item) => item.id === id)?.label || id; | |
| } | |
| function segmentLabel(trust: number, anxiety: number, participation: number) { | |
| if (anxiety > 0.68) return "생활압박층"; | |
| if (trust > 0.6 && participation > 0.55) return "제도참여층"; | |
| if (trust < 0.42) return "불신/관망층"; | |
| if (participation > 0.64) return "고관여층"; | |
| return "중도실용층"; | |
| } | |
| function mean(values: number[]) { | |
| return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0; | |
| } | |
| function noise(rand: () => number, span: number) { | |
| return (rand() - 0.5) * span * 2; | |
| } | |
| function clamp01(value: number) { | |
| return Math.max(0, Math.min(1, value)); | |
| } | |
| function mulberry32(seed: number) { | |
| let state = seed >>> 0; | |
| return () => { | |
| state += 0x6d2b79f5; | |
| let t = state; | |
| t = Math.imul(t ^ (t >>> 15), t | 1); | |
| t ^= t + Math.imul(t ^ (t >>> 7), t | 61); | |
| return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | |
| }; | |
| } | |