248 lines
8.7 KiB
Dart
248 lines
8.7 KiB
Dart
import '../models/sake_item.dart';
|
||
import 'mbti_types.dart';
|
||
|
||
/// MBTIタイプと日本酒の相性を計算するサービス
|
||
///
|
||
/// ユーザーの公式MBTIタイプと、日本酒のタグ・特定条件・五味チャートを照合して
|
||
/// 相性スコア(0.0〜1.0)と理由を返す
|
||
class MBTICompatibilityService {
|
||
|
||
/// 16タイプ別の五味チャート好み(1-5スケール)を公開
|
||
/// { aroma, sweetness, acidity, bitterness, body }
|
||
static Map<String, Map<String, double>> get flavorPreferences => _flavorPreferences;
|
||
|
||
/// 16タイプ別の五味チャート好み(1-5スケール)
|
||
/// { aroma, sweetness, acidity, bitterness, body }
|
||
static const Map<String, Map<String, double>> _flavorPreferences = {
|
||
// 分析家グループ(NT型)- 論理的、複雑な味わい好み
|
||
'INTJ': {'aroma': 4.5, 'sweetness': 2.0, 'acidity': 3.5, 'bitterness': 4.0, 'body': 4.0},
|
||
'INTP': {'aroma': 4.0, 'sweetness': 2.5, 'acidity': 4.0, 'bitterness': 3.5, 'body': 3.0},
|
||
'ENTJ': {'aroma': 4.5, 'sweetness': 2.0, 'acidity': 3.0, 'bitterness': 4.5, 'body': 4.5},
|
||
'ENTP': {'aroma': 4.0, 'sweetness': 3.5, 'acidity': 4.5, 'bitterness': 3.0, 'body': 3.5},
|
||
|
||
// 外交官グループ(NF型)- 感性的、華やかで甘め好み
|
||
'INFJ': {'aroma': 4.5, 'sweetness': 3.5, 'acidity': 3.0, 'bitterness': 2.5, 'body': 4.0},
|
||
'INFP': {'aroma': 5.0, 'sweetness': 4.0, 'acidity': 2.5, 'bitterness': 2.0, 'body': 3.0},
|
||
'ENFJ': {'aroma': 4.5, 'sweetness': 3.5, 'acidity': 3.0, 'bitterness': 2.5, 'body': 3.5},
|
||
'ENFP': {'aroma': 5.0, 'sweetness': 4.5, 'acidity': 3.5, 'bitterness': 2.0, 'body': 3.0},
|
||
|
||
// 番人グループ(SJ型)- 伝統的、バランス重視
|
||
'ISTJ': {'aroma': 2.5, 'sweetness': 2.5, 'acidity': 3.0, 'bitterness': 4.0, 'body': 4.0},
|
||
'ISFJ': {'aroma': 3.0, 'sweetness': 4.0, 'acidity': 2.5, 'bitterness': 2.5, 'body': 3.5},
|
||
'ESTJ': {'aroma': 2.5, 'sweetness': 2.0, 'acidity': 3.5, 'bitterness': 4.5, 'body': 4.0},
|
||
'ESFJ': {'aroma': 3.5, 'sweetness': 3.5, 'acidity': 3.0, 'bitterness': 3.0, 'body': 3.5},
|
||
|
||
// 探検家グループ(SP型)- 冒険的、インパクト重視
|
||
'ISTP': {'aroma': 3.0, 'sweetness': 2.0, 'acidity': 3.5, 'bitterness': 4.0, 'body': 5.0},
|
||
'ISFP': {'aroma': 4.5, 'sweetness': 4.0, 'acidity': 3.5, 'bitterness': 2.0, 'body': 3.0},
|
||
'ESTP': {'aroma': 3.0, 'sweetness': 2.0, 'acidity': 4.0, 'bitterness': 4.5, 'body': 5.0},
|
||
'ESFP': {'aroma': 5.0, 'sweetness': 4.5, 'acidity': 4.0, 'bitterness': 2.0, 'body': 2.5},
|
||
};
|
||
|
||
/// 16タイプ別の酒種好み(複数条件)
|
||
static const Map<String, List<String>> _sakeTypePreferences = {
|
||
'INTJ': ['大吟醸', '純米大吟醸', '斗瓶'],
|
||
'INTP': ['実験', '低アルコール', '酵母'],
|
||
'ENTJ': ['大吟醸', '純米大吟醸', '金賞', 'プレミアム'],
|
||
'ENTP': ['貴醸酒', '古酒', '熟成'],
|
||
'INFJ': ['古酒', '熟成', '秘蔵'],
|
||
'INFP': ['春', '夏', '秋', '冬', '限定', '花', '桜'],
|
||
'ENFJ': ['純米吟醸', '吟醸'],
|
||
'ENFP': ['限定', 'コラボ', '可愛い'],
|
||
'ISTJ': ['生酛', '山廃', '純米', '本醸造'],
|
||
'ISFJ': ['特別純米', '純米'],
|
||
'ESTJ': ['辛口', '本醸造', '淡麗'],
|
||
'ESFJ': ['純米', '食中酒', '地酒'],
|
||
'ISTP': ['無濾過', '生原酒', '原酒'],
|
||
'ISFP': ['スパークリング', '微発泡', 'にごり'],
|
||
'ESTP': ['原酒', '高アルコール', '超辛口'],
|
||
'ESFP': ['スパークリング', 'にごり', 'ピンク'],
|
||
};
|
||
|
||
/// 相性スコアを計算(0.0〜1.0)
|
||
static CompatibilityResult calculateCompatibility(String? mbtiType, SakeItem sake) {
|
||
if (mbtiType == null || mbtiType.isEmpty) {
|
||
return CompatibilityResult.noMbti();
|
||
}
|
||
|
||
final typeProfile = MBTIType.types[mbtiType];
|
||
if (typeProfile == null) {
|
||
return CompatibilityResult.noMbti();
|
||
}
|
||
|
||
double score = 0.0;
|
||
final reasons = <String>[];
|
||
double maxPossibleScore = 0.0;
|
||
|
||
// 1. 五味チャートマッチング(最大40点)
|
||
final flavorPref = _flavorPreferences[mbtiType];
|
||
final tasteStats = sake.hiddenSpecs.tasteStats;
|
||
|
||
if (flavorPref != null && tasteStats.isNotEmpty) {
|
||
maxPossibleScore += 40;
|
||
double flavorScore = 0;
|
||
int matchedAxes = 0;
|
||
|
||
for (final axis in ['aroma', 'sweetness', 'acidity', 'bitterness', 'body']) {
|
||
final pref = flavorPref[axis] ?? 3.0;
|
||
final actual = (tasteStats[axis] ?? 3).toDouble();
|
||
final diff = (pref - actual).abs();
|
||
|
||
// 差が1以下なら高スコア、2以下なら中スコア
|
||
if (diff <= 1.0) {
|
||
flavorScore += 8; // 8点/軸
|
||
matchedAxes++;
|
||
} else if (diff <= 2.0) {
|
||
flavorScore += 4; // 4点/軸
|
||
}
|
||
}
|
||
|
||
score += flavorScore;
|
||
|
||
if (matchedAxes >= 3) {
|
||
reasons.add('五味チャートが好みに合致');
|
||
} else if (matchedAxes >= 2) {
|
||
reasons.add('味わいのバランスが良好');
|
||
}
|
||
} else {
|
||
// 五味データがない場合はニュートラル加点
|
||
maxPossibleScore += 40;
|
||
score += 20;
|
||
}
|
||
|
||
// 2. タグマッチング(最大30点)
|
||
final sakeTags = sake.hiddenSpecs.flavorTags.map((t) => t.toLowerCase()).toList();
|
||
final favoriteTags = typeProfile.favorableTags.map((t) => t.toLowerCase()).toList();
|
||
|
||
maxPossibleScore += 30;
|
||
int tagMatchCount = 0;
|
||
final matchedTags = <String>[];
|
||
|
||
for (final tag in sakeTags) {
|
||
for (final fav in favoriteTags) {
|
||
if (tag.contains(fav) || fav.contains(tag)) {
|
||
tagMatchCount++;
|
||
matchedTags.add(fav);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 最大3タグまでカウント(各10点)
|
||
score += (tagMatchCount.clamp(0, 3) * 10);
|
||
|
||
if (matchedTags.isNotEmpty) {
|
||
reasons.add('「${matchedTags.first}」にマッチ');
|
||
}
|
||
|
||
// 3. 酒種マッチング(最大20点)
|
||
final sakeType = sake.hiddenSpecs.type?.toLowerCase() ?? '';
|
||
final sakeName = sake.displayData.displayName.toLowerCase();
|
||
final typePref = _sakeTypePreferences[mbtiType] ?? [];
|
||
|
||
maxPossibleScore += 20;
|
||
bool typeMatched = false;
|
||
|
||
for (final pref in typePref) {
|
||
if (sakeType.contains(pref.toLowerCase()) || sakeName.contains(pref.toLowerCase())) {
|
||
score += 20;
|
||
typeMatched = true;
|
||
reasons.add('おすすめ酒種にマッチ');
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 酒種マッチがなくてもニュートラル加点
|
||
if (!typeMatched) {
|
||
score += 10;
|
||
}
|
||
|
||
// 4. 評価補正(最大10点)
|
||
maxPossibleScore += 10;
|
||
final rating = sake.displayData.rating ?? 3.0;
|
||
if (rating >= 4.0) {
|
||
score += 10;
|
||
if (reasons.length < 3) reasons.add('高評価銘柄');
|
||
} else if (rating >= 3.0) {
|
||
score += 5;
|
||
}
|
||
|
||
// 最終スコア計算(0.0〜1.0に正規化)
|
||
final normalizedScore = maxPossibleScore > 0
|
||
? (score / maxPossibleScore).clamp(0.3, 1.0)
|
||
: 0.5;
|
||
|
||
// 理由がない場合のデフォルト
|
||
if (reasons.isEmpty) {
|
||
reasons.add('バランスの良い一本');
|
||
}
|
||
|
||
return CompatibilityResult(
|
||
score: normalizedScore,
|
||
mbtiType: mbtiType,
|
||
reasons: reasons,
|
||
);
|
||
}
|
||
|
||
/// 相性パーセンテージを取得(表示用)
|
||
static int getCompatibilityPercent(String? mbtiType, SakeItem sake) {
|
||
final result = calculateCompatibility(mbtiType, sake);
|
||
return (result.score * 100).round();
|
||
}
|
||
}
|
||
|
||
/// 相性計算結果
|
||
class CompatibilityResult {
|
||
final double score;
|
||
final String? mbtiType;
|
||
final List<String> reasons;
|
||
final bool hasResult;
|
||
|
||
CompatibilityResult({
|
||
required this.score,
|
||
required this.mbtiType,
|
||
required this.reasons,
|
||
this.hasResult = true,
|
||
});
|
||
|
||
factory CompatibilityResult.noMbti() {
|
||
return CompatibilityResult(
|
||
score: 0.0,
|
||
mbtiType: null,
|
||
reasons: [],
|
||
hasResult: false,
|
||
);
|
||
}
|
||
|
||
/// パーセンテージ表示
|
||
int get percent => (score * 100).round();
|
||
|
||
/// 星評価(0〜5)
|
||
int get starRating {
|
||
if (score >= 0.9) return 5;
|
||
if (score >= 0.75) return 4;
|
||
if (score >= 0.6) return 3;
|
||
if (score >= 0.45) return 2;
|
||
return 1;
|
||
}
|
||
|
||
/// 星評価文字列
|
||
String get starDisplay {
|
||
const filledStar = '★';
|
||
const emptyStar = '☆';
|
||
return filledStar * starRating + emptyStar * (5 - starRating);
|
||
}
|
||
|
||
/// 相性レベル
|
||
String get level {
|
||
if (score >= 0.85) return '最高';
|
||
if (score >= 0.70) return '良好';
|
||
if (score >= 0.55) return '普通';
|
||
if (score >= 0.40) return 'まあまあ';
|
||
return 'お試し';
|
||
}
|
||
|
||
/// 主な理由(1つのみ)
|
||
String get primaryReason => reasons.isNotEmpty ? reasons.first : '';
|
||
}
|